use iced::highlighter::{self, Highlighter}; use iced::keyboard; use iced::widget::{ button, column, container, horizontal_space, pick_list, row, text, text_editor, tooltip, }; use iced::{Alignment, Command, Element, Font, Length, Subscription, Theme}; use std::ffi; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; pub fn main() -> iced::Result { iced::program("Editor - Iced", Editor::update, Editor::view) .load(Editor::load) .subscription(Editor::subscription) .theme(Editor::theme) .font(include_bytes!("../fonts/icons.ttf").as_slice()) .default_font(Font::MONOSPACE) .run() } struct Editor { file: Option, content: text_editor::Content, theme: highlighter::Theme, is_loading: bool, is_dirty: bool, } #[derive(Debug, Clone)] enum Message { ActionPerformed(text_editor::Action), ThemeSelected(highlighter::Theme), NewFile, OpenFile, FileOpened(Result<(PathBuf, Arc), Error>), SaveFile, FileSaved(Result), } impl Editor { fn new() -> Self { Self { file: None, content: text_editor::Content::new(), theme: highlighter::Theme::SolarizedDark, is_loading: true, is_dirty: false, } } fn load() -> Command { Command::perform( load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), Message::FileOpened, ) } fn update(&mut self, message: Message) -> Command { match message { Message::ActionPerformed(action) => { self.is_dirty = self.is_dirty || action.is_edit(); self.content.perform(action); Command::none() } Message::ThemeSelected(theme) => { self.theme = theme; Command::none() } Message::NewFile => { if !self.is_loading { self.file = None; self.content = text_editor::Content::new(); } Command::none() } Message::OpenFile => { if self.is_loading { Command::none() } else { self.is_loading = true; Command::perform(open_file(), Message::FileOpened) } } Message::FileOpened(result) => { self.is_loading = false; self.is_dirty = false; if let Ok((path, contents)) = result { self.file = Some(path); self.content = text_editor::Content::with_text(&contents); } Command::none() } Message::SaveFile => { if self.is_loading { Command::none() } else { self.is_loading = true; Command::perform( save_file(self.file.clone(), self.content.text()), Message::FileSaved, ) } } Message::FileSaved(result) => { self.is_loading = false; if let Ok(path) = result { self.file = Some(path); self.is_dirty = false; } Command::none() } } } fn subscription(&self) -> Subscription { keyboard::on_key_press(|key, modifiers| match key.as_ref() { keyboard::Key::Character("s") if modifiers.command() => { Some(Message::SaveFile) } _ => None, }) } fn view(&self) -> Element { let controls = row![ action(new_icon(), "New file", Some(Message::NewFile)), action( open_icon(), "Open file", (!self.is_loading).then_some(Message::OpenFile) ), action( save_icon(), "Save file", self.is_dirty.then_some(Message::SaveFile) ), horizontal_space(), pick_list( highlighter::Theme::ALL, Some(self.theme), Message::ThemeSelected ) .text_size(14) .padding([5, 10]) ] .spacing(10) .align_items(Alignment::Center); let status = row![ text(if let Some(path) = &self.file { let path = path.display().to_string(); if path.len() > 60 { format!("...{}", &path[path.len() - 40..]) } else { path } } else { String::from("New file") }), horizontal_space(), text({ let (line, column) = self.content.cursor_position(); format!("{}:{}", line + 1, column + 1) }) ] .spacing(10); column![ controls, text_editor(&self.content) .height(Length::Fill) .on_action(Message::ActionPerformed) .highlight::( highlighter::Settings { theme: self.theme, extension: self .file .as_deref() .and_then(Path::extension) .and_then(ffi::OsStr::to_str) .map(str::to_string) .unwrap_or(String::from("rs")), }, |highlight, _theme| highlight.to_format() ), status, ] .spacing(10) .padding(10) .into() } fn theme(&self) -> Theme { if self.theme.is_dark() { Theme::Dark } else { Theme::Light } } } impl Default for Editor { fn default() -> Self { Self::new() } } #[derive(Debug, Clone)] pub enum Error { DialogClosed, IoError(io::ErrorKind), } async fn open_file() -> Result<(PathBuf, Arc), Error> { let picked_file = rfd::AsyncFileDialog::new() .set_title("Open a text file...") .pick_file() .await .ok_or(Error::DialogClosed)?; load_file(picked_file).await } async fn load_file( path: impl Into, ) -> Result<(PathBuf, Arc), Error> { let path = path.into(); let contents = tokio::fs::read_to_string(&path) .await .map(Arc::new) .map_err(|error| Error::IoError(error.kind()))?; Ok((path, contents)) } async fn save_file( path: Option, contents: String, ) -> Result { let path = if let Some(path) = path { path } else { rfd::AsyncFileDialog::new() .save_file() .await .as_ref() .map(rfd::FileHandle::path) .map(Path::to_owned) .ok_or(Error::DialogClosed)? }; tokio::fs::write(&path, contents) .await .map_err(|error| Error::IoError(error.kind()))?; Ok(path) } fn action<'a, Message: Clone + 'a>( content: impl Into>, label: &'a str, on_press: Option, ) -> Element<'a, Message> { let action = button(container(content).center_x(30)); if let Some(on_press) = on_press { tooltip( action.on_press(on_press), label, tooltip::Position::FollowCursor, ) .style(container::rounded_box) .into() } else { action.style(button::secondary).into() } } fn new_icon<'a, Message>() -> Element<'a, Message> { icon('\u{0e800}') } fn save_icon<'a, Message>() -> Element<'a, Message> { icon('\u{0e801}') } fn open_icon<'a, Message>() -> Element<'a, Message> { icon('\u{0f115}') } fn icon<'a, Message>(codepoint: char) -> Element<'a, Message> { const ICON_FONT: Font = Font::with_name("editor-icons"); text(codepoint).font(ICON_FONT).into() }