undo/redo stack + find/replace bar with Cmd+F/G shortcuts

This commit is contained in:
jess 2026-04-08 03:12:52 -07:00
parent 01f34a4f34
commit 57488a2861
3 changed files with 513 additions and 39 deletions

View File

@ -159,7 +159,7 @@ class IcedViewportView: NSView {
if cmd && !shift {
switch chars {
case "a", "b", "c", "i", "v", "x", "z", "p", "t",
case "a", "b", "c", "e", "f", "g", "i", "v", "x", "z", "p", "t",
"=", "+", "-", "0":
keyDown(with: event)
return true
@ -168,7 +168,7 @@ class IcedViewportView: NSView {
}
if cmd && shift {
switch chars {
case "z":
case "g", "z":
keyDown(with: event)
return true
default: break
@ -214,4 +214,16 @@ class IcedViewportView: NSView {
viewport_free_string(cstr)
return result
}
func sendCommand(_ command: UInt32) {
guard let h = viewportHandle else { return }
viewport_send_command(h, command)
}
func setTheme(_ name: String) {
guard let h = viewportHandle else { return }
name.withCString { cstr in
viewport_set_theme(h, cstr)
}
}
}

View File

@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::Instant;
use iced_wgpu::core::keyboard;
use iced_wgpu::core::keyboard::key;
@ -11,7 +12,10 @@ 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};
#[derive(Debug, Clone)]
@ -28,11 +32,62 @@ pub enum Message {
ZoomIn,
ZoomOut,
ZoomReset,
Undo,
Redo,
ToggleFind,
HideFind,
FindQueryChanged(String),
FindNext,
FindPrev,
ReplaceQueryChanged(String),
ReplaceOne,
ReplaceAll,
}
pub const RESULT_PREFIX: &str = "";
pub const ERROR_PREFIX: &str = "";
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<iced_wgpu::Renderer>,
pub font_size: f32,
@ -40,20 +95,29 @@ pub struct EditorState {
pub parsed: Vec<markdown::Item>,
pub lang: Option<String>,
scroll_offset: f32,
undo_stack: Vec<UndoSnapshot>,
redo_stack: Vec<UndoSnapshot>,
last_edit_kind: EditKind,
last_edit_time: Instant,
pub find: FindState,
pub pending_focus: Option<WidgetId>,
}
fn md_style() -> markdown::Style {
let p = palette::current();
markdown::Style {
font: Font::default(),
inline_code_highlight: Highlight {
background: Color::from_rgb(0.188, 0.188, 0.259).into(),
background: p.surface0.into(),
border: border::rounded(4),
},
inline_code_padding: padding::left(2).right(2),
inline_code_color: Color::from_rgb(0.651, 0.890, 0.631),
inline_code_color: p.green,
inline_code_font: Font::MONOSPACE,
code_block_font: Font::MONOSPACE,
link_color: Color::from_rgb(0.537, 0.706, 0.980),
link_color: p.blue,
}
}
@ -91,6 +155,12 @@ impl EditorState {
parsed: Vec::new(),
lang: Some("rust".into()),
scroll_offset: 0.0,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
last_edit_kind: EditKind::Other,
last_edit_time: Instant::now(),
find: FindState::new(),
pending_focus: None,
}
}
@ -186,11 +256,144 @@ impl EditorState {
});
}
pub fn take_pending_focus(&mut self) -> Option<WidgetId> {
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<EditKind> {
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) => {
let is_edit = action.is_edit();
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;
@ -312,6 +515,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<String> = 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();
}
}
}
@ -330,12 +634,15 @@ impl EditorState {
)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.08, 0.08, 0.10))),
.style(|_theme: &Theme| {
let p = palette::current();
container::Style {
background: Some(Background::Color(p.base)),
border: Border::default(),
text_color: Some(Color::from_rgb(0.804, 0.839, 0.957)),
text_color: Some(p.text),
shadow: Shadow::default(),
snap: false,
}
})
.into()
} else {
@ -348,12 +655,15 @@ impl EditorState {
.padding(Padding { top: top_pad, right: 8.0, bottom: 8.0, left: 8.0 })
.wrapping(Wrapping::Word)
.key_binding(macos_key_binding)
.style(|_theme, _status| Style {
background: Background::Color(Color::from_rgb(0.08, 0.08, 0.10)),
.style(|_theme, _status| {
let p = palette::current();
Style {
background: Background::Color(p.base),
border: Border::default(),
placeholder: Color::from_rgb(0.4, 0.4, 0.4),
value: Color::WHITE,
selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4),
placeholder: p.overlay0,
value: p.text,
selection: Color { a: 0.4, ..p.blue },
}
});
let settings = SyntaxSettings {
@ -365,7 +675,7 @@ impl EditorState {
settings,
|highlight, _theme| Format {
color: Some(syntax::highlight_color(highlight.kind)),
font: None,
font: syntax::highlight_font(highlight.kind),
},
)
.into();
@ -401,29 +711,149 @@ impl EditorState {
iced_widget::text(format!("{mode_label} Ln {line}, Col {col}"))
.font(Font::MONOSPACE)
.size(11.0)
.color(Color::from_rgb(0.55, 0.55, 0.55))
.color(palette::current().overlay1)
.into(),
])
)
.width(Length::Fill)
.padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 })
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.12, 0.12, 0.14))),
.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,
}
});
let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> =
vec![main_content];
let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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 {
@ -463,7 +893,7 @@ impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Gutter {
frame.fill_rectangle(
Point::ORIGIN,
bounds.size(),
Color::from_rgb(0.06, 0.06, 0.08),
palette::current().crust,
);
let first_visible = (self.scroll_offset / lh).floor() as usize;
@ -481,10 +911,11 @@ impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Gutter {
if y + lh < 0.0 || y > bounds.height {
continue;
}
let p = palette::current();
let color = if line_idx == self.cursor_line {
Color::from_rgb(0.55, 0.55, 0.62)
p.overlay1
} else {
Color::from_rgb(0.35, 0.35, 0.42)
p.surface2
};
frame.fill_text(canvas::Text {
content: format!("{}", line_idx + 1),
@ -645,6 +1076,12 @@ fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> {
}
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))
}
@ -736,3 +1173,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<usize> {
s.char_indices().nth(char_idx).map(|(i, _)| i)
}

View File

@ -12,6 +12,7 @@ use raw_window_handle::{
};
use crate::editor::{EditorState, Message};
use crate::palette;
use crate::ViewportHandle;
struct MacClipboard;
@ -158,22 +159,35 @@ pub fn render(handle: &mut ViewportHandle) {
let mut messages: Vec<Message> = Vec::new();
for event in &handle.events {
if let Event::Keyboard(keyboard::Event::KeyPressed {
match event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c),
modifiers,
..
}) = event
{
if modifiers.logo() {
}) 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);
}
}
_ => {}
}
}
@ -192,6 +206,8 @@ pub fn render(handle: &mut ViewportHandle) {
handle.state.update(msg);
}
let pending_focus = handle.state.take_pending_focus();
let theme = Theme::Dark;
let style = Style {
text_color: Color::WHITE,
@ -204,13 +220,18 @@ 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();
let bg = Color::from_rgb(0.08, 0.08, 0.10);
handle
.renderer
.present(Some(bg), handle.format, &view, &handle.viewport);
.present(Some(palette::current().base), handle.format, &view, &handle.viewport);
frame.present();
}