From e7326f0af6f16cf2ff04fbac93bf296a044923f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Mon, 18 Sep 2023 19:07:41 +0200 Subject: [PATCH] Flesh out the `editor` example a bit more --- core/src/renderer/null.rs | 4 + core/src/text/editor.rs | 8 + examples/editor/Cargo.toml | 8 +- examples/editor/fonts/icons.ttf | Bin 0 -> 6352 bytes examples/editor/src/main.rs | 287 +++++++++++++++++++++++++++++--- graphics/src/text/editor.rs | 8 +- src/settings.rs | 8 + widget/src/text_editor.rs | 4 + winit/src/application.rs | 9 +- winit/src/settings.rs | 4 + 10 files changed, 312 insertions(+), 28 deletions(-) create mode 100644 examples/editor/fonts/icons.ttf diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index 21597c8e52..da0f32de47 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -125,6 +125,10 @@ impl text::Editor for () { text::editor::Cursor::Caret(Point::ORIGIN) } + fn cursor_position(&self) -> (usize, usize) { + (0, 0) + } + fn selection(&self) -> Option { None } diff --git a/core/src/text/editor.rs b/core/src/text/editor.rs index 2144715f4a..13bafc3df8 100644 --- a/core/src/text/editor.rs +++ b/core/src/text/editor.rs @@ -12,6 +12,8 @@ pub trait Editor: Sized + Default { fn cursor(&self) -> Cursor; + fn cursor_position(&self) -> (usize, usize); + fn selection(&self) -> Option; fn line(&self, index: usize) -> Option<&str>; @@ -52,6 +54,12 @@ pub enum Action { Drag(Point), } +impl Action { + pub fn is_edit(&self) -> bool { + matches!(self, Self::Edit(_)) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum Edit { Insert(char), diff --git a/examples/editor/Cargo.toml b/examples/editor/Cargo.toml index 930ee592b3..eeb34aa117 100644 --- a/examples/editor/Cargo.toml +++ b/examples/editor/Cargo.toml @@ -7,6 +7,10 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["advanced", "debug"] +iced.features = ["advanced", "tokio", "debug"] -syntect = "5.1" \ No newline at end of file +tokio.workspace = true +tokio.features = ["fs"] + +syntect = "5.1" +rfd = "0.12" diff --git a/examples/editor/fonts/icons.ttf b/examples/editor/fonts/icons.ttf new file mode 100644 index 0000000000000000000000000000000000000000..393c6922e52f0fce49800b294962763daee5a0f3 GIT binary patch literal 6352 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 iced::Result { - Editor::run(Settings::default()) + Editor::run(Settings { + fonts: vec![include_bytes!("../fonts/icons.ttf").as_slice().into()], + default_font: Font { + monospaced: true, + ..Font::with_name("Hasklug Nerd Font Mono") + }, + ..Settings::default() + }) } struct Editor { + file: Option, content: text_editor::Content, theme: highlighter::Theme, + is_loading: bool, + is_dirty: bool, } #[derive(Debug, Clone)] enum Message { Edit(text_editor::Action), ThemeSelected(highlighter::Theme), + NewFile, + OpenFile, + FileOpened(Result<(PathBuf, Arc), Error>), + SaveFile, + FileSaved(Result), } -impl Sandbox for Editor { +impl Application for Editor { type Message = Message; - - fn new() -> Self { - Self { - content: text_editor::Content::with(include_str!("main.rs")), - theme: highlighter::Theme::SolarizedDark, - } + 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) { + fn update(&mut self, message: Message) -> Command { match message { Message::Edit(action) => { + self.is_dirty = self.is_dirty || action.is_edit(); + self.content.edit(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(&contents); + } + + Command::none() + } + Message::SaveFile => { + if self.is_loading { + Command::none() + } else { + self.is_loading = true; + + let mut contents = self.content.lines().enumerate().fold( + String::new(), + |mut contents, (i, line)| { + if i > 0 { + contents.push_str("\n"); + } + + contents.push_str(&line); + + contents + }, + ); + + if !contents.ends_with("\n") { + contents.push_str("\n"); + } + + Command::perform( + save_file(self.file.clone(), contents), + 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 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); + + 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![ - row![ - horizontal_space(Length::Fill), - pick_list( - highlighter::Theme::ALL, - Some(self.theme), - Message::ThemeSelected - ) - .padding([5, 10]) - ] - .spacing(10), + controls, text_editor(&self.content) .on_edit(Message::Edit) - .font(Font::with_name("Hasklug Nerd Font Mono")) .highlight::(highlighter::Settings { theme: self.theme, - extension: String::from("rs"), + extension: self + .file + .as_deref() + .and_then(Path::extension) + .and_then(ffi::OsStr::to_str) + .map(str::to_string) + .unwrap_or(String::from("rs")), }), + status, ] .spacing(10) - .padding(20) + .padding(10) .into() } @@ -73,6 +221,97 @@ impl Sandbox for Editor { } } +#[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)? + }; + + let _ = 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(Length::Fill).center_x()).width(40); + + 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() +} + mod highlighter { use iced::advanced::text::highlighter; use iced::widget::text_editor; diff --git a/graphics/src/text/editor.rs b/graphics/src/text/editor.rs index 4673fce338..dfb91f3452 100644 --- a/graphics/src/text/editor.rs +++ b/graphics/src/text/editor.rs @@ -221,6 +221,12 @@ impl editor::Editor for Editor { } } + fn cursor_position(&self) -> (usize, usize) { + let cursor = self.internal().editor.cursor(); + + (cursor.line, cursor.index) + } + fn perform(&mut self, action: Action) { let mut font_system = text::font_system().write().expect("Write font system"); @@ -559,7 +565,7 @@ impl editor::Editor for Editor { Some(i) } }) - .unwrap_or(buffer.lines.len()); + .unwrap_or(buffer.lines.len().saturating_sub(1)); let current_line = highlighter.current_line(); diff --git a/src/settings.rs b/src/settings.rs index d9778d7e3b..6b9ce09518 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -2,6 +2,8 @@ use crate::window; use crate::{Font, Pixels}; +use std::borrow::Cow; + /// The settings of an application. #[derive(Debug, Clone)] pub struct Settings { @@ -21,6 +23,9 @@ pub struct Settings { /// [`Application`]: crate::Application pub flags: Flags, + /// The fonts to load on boot. + pub fonts: Vec>, + /// The default [`Font`] to be used. /// /// By default, it uses [`Family::SansSerif`](crate::font::Family::SansSerif). @@ -62,6 +67,7 @@ impl Settings { flags, id: default_settings.id, window: default_settings.window, + fonts: default_settings.fonts, default_font: default_settings.default_font, default_text_size: default_settings.default_text_size, antialiasing: default_settings.antialiasing, @@ -79,6 +85,7 @@ where id: None, window: Default::default(), flags: Default::default(), + fonts: Default::default(), default_font: Default::default(), default_text_size: Pixels(16.0), antialiasing: false, @@ -93,6 +100,7 @@ impl From> for iced_winit::Settings { id: settings.id, window: settings.window.into(), flags: settings.flags, + fonts: settings.fonts, exit_on_close_request: settings.exit_on_close_request, } } diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index 0cde2c983f..970ec0310c 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -182,6 +182,10 @@ where pub fn selection(&self) -> Option { self.0.borrow().editor.selection() } + + pub fn cursor_position(&self) -> (usize, usize) { + self.0.borrow().editor.cursor_position() + } } impl Default for Content diff --git a/winit/src/application.rs b/winit/src/application.rs index d1689452b0..e80e978370 100644 --- a/winit/src/application.rs +++ b/winit/src/application.rs @@ -193,7 +193,14 @@ where }; } - let (compositor, renderer) = C::new(compositor_settings, Some(&window))?; + let (compositor, mut renderer) = + C::new(compositor_settings, Some(&window))?; + + for font in settings.fonts { + use crate::core::text::Renderer; + + renderer.load_font(font); + } let (mut event_sender, event_receiver) = mpsc::unbounded(); let (control_sender, mut control_receiver) = mpsc::unbounded(); diff --git a/winit/src/settings.rs b/winit/src/settings.rs index 8d3e1b47fd..b4a1dd6135 100644 --- a/winit/src/settings.rs +++ b/winit/src/settings.rs @@ -33,6 +33,7 @@ use crate::Position; use winit::monitor::MonitorHandle; use winit::window::WindowBuilder; +use std::borrow::Cow; use std::fmt; /// The settings of an application. @@ -52,6 +53,9 @@ pub struct Settings { /// [`Application`]: crate::Application pub flags: Flags, + /// The fonts to load on boot. + pub fonts: Vec>, + /// Whether the [`Application`] should exit when the user requests the /// window to close (e.g. the user presses the close button). ///