diff --git a/src/IcedViewportView.swift b/src/IcedViewportView.swift index 517886c..67d7439 100644 --- a/src/IcedViewportView.swift +++ b/src/IcedViewportView.swift @@ -168,7 +168,7 @@ class IcedViewportView: NSView { } if cmd && shift { switch chars { - case "z": + case "g", "z": keyDown(with: event) return true default: break diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 6815681..7d9855f 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -12,7 +12,9 @@ use iced_widget::canvas; use iced_widget::container; use iced_widget::markdown; use iced_widget::text_editor::{self, Action, Binding, Cursor, KeyPress, Motion, Position, Status, Style}; +use iced_widget::text_input; use iced_wgpu::core::text::highlighter::Format; +use iced_wgpu::core::widget::Id as WidgetId; use crate::palette; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings}; @@ -30,6 +32,16 @@ pub enum Message { ZoomIn, ZoomOut, ZoomReset, + Undo, + Redo, + ToggleFind, + HideFind, + FindQueryChanged(String), + FindNext, + FindPrev, + ReplaceQueryChanged(String), + ReplaceOne, + ReplaceAll, } pub const RESULT_PREFIX: &str = "→ "; @@ -37,6 +49,47 @@ pub const ERROR_PREFIX: &str = "⚠ "; const EVAL_DEBOUNCE_MS: u128 = 300; +pub const FIND_INPUT_ID: &str = "find_input"; +pub const REPLACE_INPUT_ID: &str = "replace_input"; +const UNDO_MAX: usize = 200; +const COALESCE_MS: u128 = 500; + +struct UndoSnapshot { + text: String, + cursor_line: usize, + cursor_col: usize, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum EditKind { + Insert, + Backspace, + Delete, + Enter, + Paste, + Other, +} + +pub struct FindState { + pub visible: bool, + pub query: String, + pub replacement: String, + pub matches: Vec<(usize, usize)>, + pub current: usize, +} + +impl FindState { + fn new() -> Self { + Self { + visible: false, + query: String::new(), + replacement: String::new(), + matches: Vec::new(), + current: 0, + } + } +} + pub struct EditorState { pub content: text_editor::Content, pub font_size: f32, @@ -46,6 +99,14 @@ pub struct EditorState { scroll_offset: f32, eval_dirty: bool, last_edit: Instant, + + undo_stack: Vec, + redo_stack: Vec, + last_edit_kind: EditKind, + last_edit_time: Instant, + + pub find: FindState, + pub pending_focus: Option, } fn md_style() -> markdown::Style { @@ -100,6 +161,12 @@ impl EditorState { scroll_offset: 0.0, eval_dirty: false, last_edit: Instant::now(), + undo_stack: Vec::new(), + redo_stack: Vec::new(), + last_edit_kind: EditKind::Other, + last_edit_time: Instant::now(), + find: FindState::new(), + pending_focus: None, } } @@ -248,6 +315,135 @@ impl EditorState { }); } + pub fn take_pending_focus(&mut self) -> Option { + self.pending_focus.take() + } + + fn snapshot(&self) -> UndoSnapshot { + let cursor = self.content.cursor(); + UndoSnapshot { + text: self.get_clean_text(), + cursor_line: cursor.position.line, + cursor_col: cursor.position.column, + } + } + + fn push_undo_snapshot(&mut self) { + let snap = self.snapshot(); + self.undo_stack.push(snap); + if self.undo_stack.len() > UNDO_MAX { + self.undo_stack.remove(0); + } + } + + fn maybe_snapshot(&mut self, kind: EditKind) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_edit_time).as_millis(); + let should_snap = kind != self.last_edit_kind + || elapsed > COALESCE_MS + || kind == EditKind::Enter + || kind == EditKind::Paste; + + if should_snap { + self.push_undo_snapshot(); + } + + self.last_edit_kind = kind; + self.last_edit_time = now; + self.redo_stack.clear(); + } + + fn classify_edit(action: &text_editor::Action) -> Option { + match action { + Action::Edit(edit) => match edit { + text_editor::Edit::Insert(_) => Some(EditKind::Insert), + text_editor::Edit::Enter => Some(EditKind::Enter), + text_editor::Edit::Backspace => Some(EditKind::Backspace), + text_editor::Edit::Delete => Some(EditKind::Delete), + text_editor::Edit::Paste(_) => Some(EditKind::Paste), + _ => Some(EditKind::Other), + }, + _ => None, + } + } + + fn restore_snapshot(&mut self, snap: &UndoSnapshot) { + self.set_text(&snap.text); + self.run_eval(); + let text = self.content.text(); + let display_line = from_clean_line(&text, snap.cursor_line); + self.content.move_to(Cursor { + position: Position { line: display_line, column: snap.cursor_col }, + selection: None, + }); + } + + fn perform_undo(&mut self) { + if self.undo_stack.is_empty() { + return; + } + let current = self.snapshot(); + self.redo_stack.push(current); + let snap = self.undo_stack.pop().unwrap(); + self.restore_snapshot(&snap); + self.last_edit_kind = EditKind::Other; + } + + fn perform_redo(&mut self) { + if self.redo_stack.is_empty() { + return; + } + let current = self.snapshot(); + self.undo_stack.push(current); + let snap = self.redo_stack.pop().unwrap(); + self.restore_snapshot(&snap); + self.last_edit_kind = EditKind::Other; + } + + fn update_find_matches(&mut self) { + self.find.matches.clear(); + self.find.current = 0; + if self.find.query.is_empty() { + return; + } + let text = self.get_clean_text(); + let query_lower = self.find.query.to_lowercase(); + let text_lower = text.to_lowercase(); + + let mut line = 0usize; + let mut col = 0usize; + let mut byte = 0usize; + + for (i, ch) in text_lower.char_indices() { + while byte < i { + byte += 1; + } + if ch == '\n' { + line += 1; + col = 0; + continue; + } + if text_lower[i..].starts_with(&query_lower) { + self.find.matches.push((line, col)); + } + col += 1; + } + } + + fn navigate_to_match(&mut self) { + if self.find.matches.is_empty() { + return; + } + let idx = self.find.current.min(self.find.matches.len() - 1); + let (line, col) = self.find.matches[idx]; + let text = self.content.text(); + let display_line = from_clean_line(&text, line); + self.content.move_to(Cursor { + position: Position { line: display_line, column: col }, + selection: None, + }); + } + pub fn update(&mut self, message: Message) { match message { Message::EditorAction(action) => { @@ -255,6 +451,10 @@ impl EditorState { let is_enter = matches!(&action, Action::Edit(text_editor::Edit::Enter)); let is_paste = matches!(&action, Action::Edit(text_editor::Edit::Paste(_))); + if let Some(kind) = Self::classify_edit(&action) { + self.maybe_snapshot(kind); + } + if let Action::Scroll { lines } = &action { let lh = self.line_height(); self.scroll_offset += *lines as f32 * lh; @@ -386,6 +586,107 @@ impl EditorState { Message::ZoomReset => { self.font_size = 14.0; } + Message::Undo => { + self.perform_undo(); + } + Message::Redo => { + self.perform_redo(); + } + Message::ToggleFind => { + self.find.visible = !self.find.visible; + if self.find.visible { + self.pending_focus = Some(WidgetId::new(FIND_INPUT_ID)); + } + } + Message::HideFind => { + self.find.visible = false; + } + Message::FindQueryChanged(q) => { + self.find.query = q; + self.update_find_matches(); + if !self.find.matches.is_empty() { + self.find.current = 0; + self.navigate_to_match(); + } + } + Message::FindNext => { + if !self.find.matches.is_empty() { + self.find.current = (self.find.current + 1) % self.find.matches.len(); + self.navigate_to_match(); + } + } + Message::FindPrev => { + if !self.find.matches.is_empty() { + self.find.current = if self.find.current == 0 { + self.find.matches.len() - 1 + } else { + self.find.current - 1 + }; + self.navigate_to_match(); + } + } + Message::ReplaceQueryChanged(r) => { + self.find.replacement = r; + } + Message::ReplaceOne => { + if self.find.matches.is_empty() || self.find.query.is_empty() { + return; + } + self.push_undo_snapshot(); + self.redo_stack.clear(); + + let (match_line, match_col) = self.find.matches[self.find.current]; + let clean = self.get_clean_text(); + let mut lines: Vec = clean.lines().map(|l| l.to_string()).collect(); + if match_line < lines.len() { + let line = &lines[match_line]; + let query_len = self.find.query.len(); + let line_lower = line.to_lowercase(); + let query_lower = self.find.query.to_lowercase(); + if let Some(byte_start) = nth_char_byte_offset(line, match_col) { + if line_lower[byte_start..].starts_with(&query_lower) { + let before = &line[..byte_start]; + let after = &line[byte_start + query_len..]; + lines[match_line] = format!("{before}{}{after}", self.find.replacement); + } + } + } + let new_text = lines.join("\n"); + self.set_text(&new_text); + self.run_eval(); + self.update_find_matches(); + if !self.find.matches.is_empty() { + self.find.current = self.find.current.min(self.find.matches.len() - 1); + self.navigate_to_match(); + } + } + Message::ReplaceAll => { + if self.find.matches.is_empty() || self.find.query.is_empty() { + return; + } + self.push_undo_snapshot(); + self.redo_stack.clear(); + + let clean = self.get_clean_text(); + let query_lower = self.find.query.to_lowercase(); + let mut result = String::with_capacity(clean.len()); + let mut i = 0; + let clean_lower = clean.to_lowercase(); + let qlen = self.find.query.len(); + while i < clean.len() { + if clean_lower[i..].starts_with(&query_lower) { + result.push_str(&self.find.replacement); + i += qlen; + } else { + let ch = clean[i..].chars().next().unwrap(); + result.push(ch); + i += ch.len_utf8(); + } + } + self.set_text(&result); + self.run_eval(); + self.update_find_matches(); + } } } @@ -503,15 +804,132 @@ impl EditorState { } }); - let mut col_items: Vec> = - vec![main_content]; + let mut col_items: Vec> = Vec::new(); + if self.find.visible { + col_items.push(self.find_bar()); + } + + col_items.push(main_content); col_items.push(status_bar.into()); iced_widget::column(col_items) .height(Length::Fill) .into() } + + fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + + let search_input = text_input::TextInput::new("Find...", &self.find.query) + .on_input(Message::FindQueryChanged) + .on_submit(Message::FindNext) + .id(WidgetId::new(FIND_INPUT_ID)) + .font(Font::MONOSPACE) + .size(13.0) + .padding(Padding { top: 3.0, right: 6.0, bottom: 3.0, left: 6.0 }) + .width(Length::FillPortion(3)) + .style(find_input_style); + + let replace_input = text_input::TextInput::new("Replace...", &self.find.replacement) + .on_input(Message::ReplaceQueryChanged) + .on_submit(Message::ReplaceOne) + .id(WidgetId::new(REPLACE_INPUT_ID)) + .font(Font::MONOSPACE) + .size(13.0) + .padding(Padding { top: 3.0, right: 6.0, bottom: 3.0, left: 6.0 }) + .width(Length::FillPortion(3)) + .style(find_input_style); + + let match_label = if self.find.matches.is_empty() { + if self.find.query.is_empty() { + String::new() + } else { + "0/0".into() + } + } else { + format!("{}/{}", self.find.current + 1, self.find.matches.len()) + }; + + let label: Element<'_, Message, Theme, iced_wgpu::Renderer> = + iced_widget::text(match_label) + .font(Font::MONOSPACE) + .size(11.0) + .color(p.overlay1) + .into(); + + let btn = |txt: String, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + iced_widget::button( + iced_widget::text(txt).font(Font::MONOSPACE).size(11.0) + ) + .on_press(msg) + .padding(Padding { top: 2.0, right: 6.0, bottom: 2.0, left: 6.0 }) + .style(find_btn_style) + .into() + }; + + let row = iced_widget::row![ + search_input, + label, + btn("Prev".into(), Message::FindPrev), + btn("Next".into(), Message::FindNext), + replace_input, + btn("Repl".into(), Message::ReplaceOne), + btn("All".into(), Message::ReplaceAll), + btn("X".into(), Message::HideFind), + ] + .spacing(4.0) + .align_y(alignment::Vertical::Center); + + iced_widget::container(row) + .width(Length::Fill) + .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 8.0 }) + .style(|_theme: &Theme| { + let p = palette::current(); + container::Style { + background: Some(Background::Color(p.mantle)), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + } + }) + .into() + } +} + +fn find_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { + let p = palette::current(); + text_input::Style { + background: Background::Color(p.surface0), + border: Border { + color: p.surface2, + width: 1.0, + radius: 3.0.into(), + }, + icon: p.overlay2, + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } +} + +fn find_btn_style( + _theme: &Theme, + _status: iced_widget::button::Status, +) -> iced_widget::button::Style { + let p = palette::current(); + iced_widget::button::Style { + background: Some(Background::Color(p.surface1)), + text_color: p.text, + border: Border { + color: p.surface2, + width: 1.0, + radius: 3.0.into(), + }, + shadow: Shadow::default(), + snap: false, + } } struct Gutter { @@ -748,6 +1166,12 @@ fn macos_key_binding(key_press: KeyPress) -> Option> { } match key.as_ref() { + keyboard::Key::Character("z") if modifiers.logo() && modifiers.shift() => { + Some(Binding::Custom(Message::Redo)) + } + keyboard::Key::Character("z") if modifiers.logo() => { + Some(Binding::Custom(Message::Undo)) + } keyboard::Key::Character("=" | "+") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomIn)) } @@ -839,3 +1263,7 @@ fn leading_whitespace(line: &str) -> &str { let end = line.len() - line.trim_start().len(); &line[..end] } + +fn nth_char_byte_offset(s: &str, char_idx: usize) -> Option { + s.char_indices().nth(char_idx).map(|(i, _)| i) +} diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 1d4d4ed..4fb9c06 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -159,22 +159,35 @@ pub fn render(handle: &mut ViewportHandle) { let mut messages: Vec = Vec::new(); for event in &handle.events { - if let Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Character(c), - modifiers, - .. - }) = event - { - if modifiers.logo() { + match event { + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if modifiers.logo() => { match c.as_str() { "p" => messages.push(Message::TogglePreview), "t" => messages.push(Message::InsertTable), "b" => messages.push(Message::ToggleBold), "i" => messages.push(Message::ToggleItalic), "e" => messages.push(Message::SmartEval), + "z" if modifiers.shift() => messages.push(Message::Redo), + "z" => messages.push(Message::Undo), + "f" => messages.push(Message::ToggleFind), + "g" if modifiers.shift() => messages.push(Message::FindPrev), + "g" => messages.push(Message::FindNext), _ => {} } } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) => { + if handle.state.find.visible { + messages.push(Message::HideFind); + } + } + _ => {} } } @@ -194,6 +207,7 @@ pub fn render(handle: &mut ViewportHandle) { } handle.state.tick(); + let pending_focus = handle.state.take_pending_focus(); let theme = Theme::Dark; let style = Style { @@ -207,6 +221,12 @@ pub fn render(handle: &mut ViewportHandle) { &mut handle.renderer, ); + if let Some(focus_id) = pending_focus { + use iced_wgpu::core::widget::operation::focusable; + let mut op = focusable::focus(focus_id); + ui.operate(&handle.renderer, &mut op); + } + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); handle.cache = ui.into_cache();