Merge branch 'features-01f34a-rc4' into features-01f34a

# Conflicts:
#	viewport/src/editor.rs
#	viewport/src/handle.rs
This commit is contained in:
jess 2026-04-08 03:35:35 -07:00
commit d50f463ebb
3 changed files with 458 additions and 10 deletions

View File

@ -168,7 +168,7 @@ class IcedViewportView: NSView {
} }
if cmd && shift { if cmd && shift {
switch chars { switch chars {
case "z": case "g", "z":
keyDown(with: event) keyDown(with: event)
return true return true
default: break default: break

View File

@ -12,7 +12,9 @@ use iced_widget::canvas;
use iced_widget::container; use iced_widget::container;
use iced_widget::markdown; use iced_widget::markdown;
use iced_widget::text_editor::{self, Action, Binding, Cursor, KeyPress, Motion, Position, Status, Style}; 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::text::highlighter::Format;
use iced_wgpu::core::widget::Id as WidgetId;
use crate::palette; use crate::palette;
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings}; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings};
@ -30,6 +32,16 @@ pub enum Message {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
ZoomReset, ZoomReset,
Undo,
Redo,
ToggleFind,
HideFind,
FindQueryChanged(String),
FindNext,
FindPrev,
ReplaceQueryChanged(String),
ReplaceOne,
ReplaceAll,
} }
pub const RESULT_PREFIX: &str = ""; pub const RESULT_PREFIX: &str = "";
@ -37,6 +49,47 @@ pub const ERROR_PREFIX: &str = "⚠ ";
const EVAL_DEBOUNCE_MS: u128 = 300; 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 struct EditorState {
pub content: text_editor::Content<iced_wgpu::Renderer>, pub content: text_editor::Content<iced_wgpu::Renderer>,
pub font_size: f32, pub font_size: f32,
@ -46,6 +99,14 @@ pub struct EditorState {
scroll_offset: f32, scroll_offset: f32,
eval_dirty: bool, eval_dirty: bool,
last_edit: Instant, last_edit: Instant,
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 { fn md_style() -> markdown::Style {
@ -100,6 +161,12 @@ impl EditorState {
scroll_offset: 0.0, scroll_offset: 0.0,
eval_dirty: false, eval_dirty: false,
last_edit: Instant::now(), 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<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) { pub fn update(&mut self, message: Message) {
match message { match message {
Message::EditorAction(action) => { Message::EditorAction(action) => {
@ -255,6 +451,10 @@ impl EditorState {
let is_enter = matches!(&action, Action::Edit(text_editor::Edit::Enter)); let is_enter = matches!(&action, Action::Edit(text_editor::Edit::Enter));
let is_paste = matches!(&action, Action::Edit(text_editor::Edit::Paste(_))); 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 { if let Action::Scroll { lines } = &action {
let lh = self.line_height(); let lh = self.line_height();
self.scroll_offset += *lines as f32 * lh; self.scroll_offset += *lines as f32 * lh;
@ -386,6 +586,107 @@ impl EditorState {
Message::ZoomReset => { Message::ZoomReset => {
self.font_size = 14.0; 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();
}
} }
} }
@ -503,15 +804,132 @@ impl EditorState {
} }
}); });
let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
vec![main_content];
if self.find.visible {
col_items.push(self.find_bar());
}
col_items.push(main_content);
col_items.push(status_bar.into()); col_items.push(status_bar.into());
iced_widget::column(col_items) iced_widget::column(col_items)
.height(Length::Fill) .height(Length::Fill)
.into() .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 { struct Gutter {
@ -748,6 +1166,12 @@ fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> {
} }
match key.as_ref() { 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() => { keyboard::Key::Character("=" | "+") if modifiers.logo() => {
Some(Binding::Custom(Message::ZoomIn)) Some(Binding::Custom(Message::ZoomIn))
} }
@ -839,3 +1263,7 @@ fn leading_whitespace(line: &str) -> &str {
let end = line.len() - line.trim_start().len(); let end = line.len() - line.trim_start().len();
&line[..end] &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

@ -159,22 +159,35 @@ pub fn render(handle: &mut ViewportHandle) {
let mut messages: Vec<Message> = Vec::new(); let mut messages: Vec<Message> = Vec::new();
for event in &handle.events { for event in &handle.events {
if let Event::Keyboard(keyboard::Event::KeyPressed { match event {
key: keyboard::Key::Character(c), Event::Keyboard(keyboard::Event::KeyPressed {
modifiers, key: keyboard::Key::Character(c),
.. modifiers,
}) = event ..
{ }) if modifiers.logo() => {
if modifiers.logo() {
match c.as_str() { match c.as_str() {
"p" => messages.push(Message::TogglePreview), "p" => messages.push(Message::TogglePreview),
"t" => messages.push(Message::InsertTable), "t" => messages.push(Message::InsertTable),
"b" => messages.push(Message::ToggleBold), "b" => messages.push(Message::ToggleBold),
"i" => messages.push(Message::ToggleItalic), "i" => messages.push(Message::ToggleItalic),
"e" => messages.push(Message::SmartEval), "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(); handle.state.tick();
let pending_focus = handle.state.take_pending_focus();
let theme = Theme::Dark; let theme = Theme::Dark;
let style = Style { let style = Style {
@ -207,6 +221,12 @@ pub fn render(handle: &mut ViewportHandle) {
&mut handle.renderer, &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); ui.draw(&mut handle.renderer, &theme, &style, handle.cursor);
handle.cache = ui.into_cache(); handle.cache = ui.into_cache();