use iced::executor; use iced::highlighter::{self, Highlighter}; use iced::keyboard; use iced::theme::{self, Theme}; use iced::widget::{ button, column, container, horizontal_space, pick_list, row, text, text_editor, tooltip, }; use iced::{ Alignment, Application, Command, Element, Font, Length, Settings, Subscription, }; use std::ffi; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; pub fn main() -> iced::Result { Editor::run(Settings { fonts: vec![include_bytes!("../fonts/icons.ttf").as_slice().into()], default_font: Font::MONOSPACE, ..Settings::default() }) } 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 Application for Editor { type Message = Message; type Theme = Theme; type Executor = executor::Default; type Flags = (); fn new(_flags: Self::Flags) -> (Self, Command) { ( Self { file: None, content: text_editor::Content::new(), theme: highlighter::Theme::SolarizedDark, is_loading: true, is_dirty: false, }, Command::perform(load_file(default_file()), Message::FileOpened), ) } fn title(&self) -> String { String::from("Editor - Iced") } 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(Length::Fill), 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(Length::Fill), text({ let (line, column) = self.content.cursor_position(); format!("{}:{}", line + 1, column + 1) }) ] .spacing(10); column![ controls, text_editor(&self.content) .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 } } } #[derive(Debug, Clone)] pub enum Error { DialogClosed, IoError(io::ErrorKind), } fn default_file() -> PathBuf { PathBuf::from(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))) } 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.path().to_owned()).await } async fn load_file(path: PathBuf) -> Result<(PathBuf, Arc), Error> { 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).width(30).center_x()); if let Some(on_press) = on_press { tooltip( action.on_press(on_press), label, tooltip::Position::FollowCursor, ) .style(theme::Container::Box) .into() } else { action.style(theme::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() }