From 1cec56e8472a1f0660a9c5f5932e7478bb804785 Mon Sep 17 00:00:00 2001 From: shadow cat Date: Fri, 21 Nov 2025 23:56:31 -0500 Subject: [PATCH] TEXT SELECTION --- src/bin/test/main.rs | 72 +++++++++++-- src/core/sense.rs | 30 ++++-- src/core/text/build.rs | 2 +- src/core/text/edit.rs | 231 +++++++++++++++++++++++++++++++++-------- src/core/text/mod.rs | 21 +++- src/layout/attr.rs | 20 ++++ src/layout/event.rs | 5 +- src/layout/mod.rs | 2 + src/layout/painter.rs | 2 - src/layout/ui.rs | 16 ++- 10 files changed, 332 insertions(+), 69 deletions(-) create mode 100644 src/layout/attr.rs diff --git a/src/bin/test/main.rs b/src/bin/test/main.rs index f3f501f..9e2f00e 100644 --- a/src/bin/test/main.rs +++ b/src/bin/test/main.rs @@ -6,7 +6,12 @@ use iris::prelude::*; use len_fns::*; use render::Renderer; use std::sync::Arc; -use winit::{event::WindowEvent, event_loop::ActiveEventLoop, window::Window}; +use winit::{ + dpi::{LogicalPosition, LogicalSize}, + event::{Ime, WindowEvent}, + event_loop::ActiveEventLoop, + window::Window, +}; mod app; mod input; @@ -18,11 +23,13 @@ fn main() { pub struct Client { renderer: Renderer, + window: Arc, input: Input, ui: Ui, info: WidgetId, focus: Option>, clipboard: Clipboard, + ime: usize, } #[derive(Eq, PartialEq, Hash, Clone)] @@ -32,9 +39,37 @@ impl DefaultEvent for Submit { type Data = (); } +pub struct Selectable; + +impl WidgetAttr for Selectable { + type Input = (); + + fn run(ui: &mut Ui, id: &WidgetId, _: Self::Input) { + let id = id.clone(); + ui.register_event( + &id.clone(), + CursorSense::click_or_drag(), + move |client: &mut Client, data| { + client + .ui + .text(&id) + .select(data.cursor, data.size, data.sense.is_dragging()); + if let Some(region) = client.ui.window_region(&id) { + client.window.set_ime_allowed(true); + client.window.set_ime_cursor_area( + LogicalPosition::::from(region.top_left.tuple()), + LogicalSize::::from(region.size().tuple()), + ); + } + client.focus = Some(id.clone()); + }, + ); + } +} + impl Client { pub fn new(window: Arc) -> Self { - let renderer = Renderer::new(window); + let renderer = Renderer::new(window.clone()); let mut ui = Ui::new(); let rrect = rect(Color::WHITE).radius(20); @@ -127,10 +162,7 @@ impl Client { .editable() .text_align(Align::LEFT) .size(30) - .id_on(CursorSense::click(), |id, client: &mut Client, ctx| { - client.ui.text(id).select(ctx.cursor, ctx.size); - client.focus = Some(id.clone()); - }) + .attr::(()) .id_on(Submit, move |id, client: &mut Client, _| { let content = client.ui.text(id).take(); let text = text(content) @@ -138,10 +170,7 @@ impl Client { .size(30) .text_align(Align::LEFT) .wrap(true) - .id_on(CursorSense::click(), |id, client: &mut Client, ctx| { - client.ui.text(id).select(ctx.cursor, ctx.size); - client.focus = Some(id.clone()); - }); + .attr::(()); let msg_box = text .background(rect(Color::WHITE.darker(0.5))) .add(&mut client.ui); @@ -213,11 +242,13 @@ impl Client { Self { renderer, + window, input: Input::default(), ui, info, focus: None, clipboard: Clipboard::new().unwrap(), + ime: 0, } } @@ -263,10 +294,31 @@ impl Client { text.insert(&t); } } + TextInputResult::Copy(text) => { + if let Err(err) = self.clipboard.set_text(text) { + eprintln!("failed to copy text to clipboard: {err}") + } + } TextInputResult::Unused | TextInputResult::Used => (), } } } + WindowEvent::Ime(ime) => { + if let Some(sel) = &self.focus { + let mut text = self.ui.text(sel); + match ime { + Ime::Enabled | Ime::Disabled => (), + Ime::Preedit(content, _pos) => { + // TODO: highlight once that's real + text.replace(self.ime, &content); + self.ime = content.chars().count(); + } + Ime::Commit(content) => { + text.insert(&content); + } + } + } + } _ => (), } let new = format!( diff --git a/src/core/sense.rs b/src/core/sense.rs index 9e7bbc4..14f7760 100644 --- a/src/core/sense.rs +++ b/src/core/sense.rs @@ -10,14 +10,14 @@ use crate::{ util::{HashMap, Id}, }; -#[derive(PartialEq)] +#[derive(Clone, Copy, PartialEq)] pub enum Button { Left, Right, Middle, } -#[derive(PartialEq)] +#[derive(Clone, Copy, PartialEq)] pub enum CursorSense { PressStart(Button), Pressing(Button), @@ -34,9 +34,15 @@ impl CursorSense { pub fn click() -> Self { Self::PressStart(Button::Left) } + pub fn click_or_drag() -> CursorSenses { + Self::click() | Self::Pressing(Button::Left) + } pub fn unclick() -> Self { Self::PressEnd(Button::Left) } + pub fn is_dragging(&self) -> bool { + matches!(self, CursorSense::Pressing(Button::Left)) + } } #[derive(Default, Clone)] @@ -108,6 +114,9 @@ pub struct CursorData { pub cursor: Vec2, pub size: Vec2, pub scroll_delta: Vec2, + /// the (first) sense that triggered this event + /// the senses are checked in order + pub sense: CursorSense, } pub struct CursorModule { @@ -188,11 +197,12 @@ impl CursorModule { sensed = true; for sensor in &mut group.sensors { - if should_run(&sensor.senses, cursor, group.hover) { + if let Some(sense) = should_run(&sensor.senses, cursor, group.hover) { let data = CursorData { cursor: cursor.pos - region.top_left, size: region.bot_right - region.top_left, scroll_delta: cursor.scroll_delta, + sense, }; (sensor.f)(ctx, data); } @@ -210,7 +220,11 @@ impl CursorModule { } } -pub fn should_run(senses: &CursorSenses, cursor: &CursorState, hover: ActivationState) -> bool { +pub fn should_run( + senses: &CursorSenses, + cursor: &CursorState, + hover: ActivationState, +) -> Option { for sense in senses.iter() { if match sense { CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(), @@ -221,10 +235,10 @@ pub fn should_run(senses: &CursorSenses, cursor: &CursorState, hover: Activation CursorSense::HoverEnd => hover.is_end(), CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO, } { - return true; + return Some(*sense); } } - false + None } impl ActivationState { @@ -280,8 +294,8 @@ impl Event for CursorSense { type Data = CursorData; } -impl::Data> + Into, Ctx: 'static> EventModule - for CursorModule +impl::Data> + Into, Ctx: 'static> + EventModule for CursorModule { fn register(&mut self, id: Id, senses: E, f: impl EventFn::Data>) { // TODO: does not add to active if currently active diff --git a/src/core/text/build.rs b/src/core/text/build.rs index 3af2065..4d5a32e 100644 --- a/src/core/text/build.rs +++ b/src/core/text/build.rs @@ -94,7 +94,7 @@ impl TextBuilderOutput for TextEditOutput { )); let mut text = TextEdit { view: TextView::new(buf, builder.attrs, builder.hint.get(ui)), - cursor: None, + selection: Default::default(), }; let font_system = &mut ui.data.text.font_system; text.buf diff --git a/src/core/text/edit.rs b/src/core/text/edit.rs index 88eea86..d9ecc19 100644 --- a/src/core/text/edit.rs +++ b/src/core/text/edit.rs @@ -10,7 +10,7 @@ use winit::{ pub struct TextEdit { pub(super) view: TextView, - pub(super) cursor: Option, + pub(super) selection: TextSelection, } impl TextEdit { @@ -21,28 +21,86 @@ impl TextEdit { .align(self.align) } - pub fn content(&self) -> String { - self.buf - .lines - .iter() - .map(|l| l.text()) - .collect::>() - .join("\n") + pub fn select_content(&self, start: Cursor, end: Cursor) -> String { + let (start, end) = sort_cursors(start, end); + let mut iter = self.buf.lines.iter().skip(start.line); + let first = iter.next().unwrap(); + if start.line == end.line { + first.text()[start.index..end.index].to_string() + } else { + let mut str = first.text()[start.index..].to_string(); + for _ in (start.line + 1)..end.line { + str = str + "\n" + iter.next().unwrap().text(); + } + let last = iter.next().unwrap(); + str = str + "\n" + &last.text()[..end.index]; + str + } } } impl Widget for TextEdit { fn draw(&mut self, painter: &mut Painter) { + let base = painter.layer; + painter.child_layer(); let region = self.view.draw(painter); + painter.layer = base; - if let Some(cursor) = &self.cursor - && let Some(offset) = cursor_pos(cursor, &self.buf) - { - let size = vec2(1, self.attrs.line_height); - painter.primitive_within( - RectPrimitive::color(Color::WHITE), - size.align(Align::TOP_LEFT).offset(offset).within(®ion), - ); + match &self.selection { + TextSelection::None => (), + TextSelection::Pos(cursor) => { + if let Some(offset) = cursor_pos(cursor, &self.buf) { + let size = vec2(1, self.attrs.line_height); + painter.primitive_within( + RectPrimitive::color(Color::WHITE), + size.align(Align::TOP_LEFT).offset(offset).within(®ion), + ); + } + } + TextSelection::Span { start, end } => { + let (start, end) = sort_cursors(*start, *end); + let line_height = self.attrs.line_height; + let mut highlight = |offset, width: f32| { + painter.primitive_within( + RectPrimitive::color(Color::SKY), + vec2(width, line_height) + .align(Align::TOP_LEFT) + .offset(offset) + .within(®ion), + ); + }; + if let (Some(start_offset), Some(end_offset)) = + (cursor_pos(&start, &self.buf), cursor_pos(&end, &self.buf)) + { + let mut iter = self.buf.layout_runs().skip(start.line); + if start.line == end.line { + highlight(start_offset, end_offset.x - start_offset.x); + } else { + let first = iter.next().unwrap(); + highlight(start_offset, first.line_w - start_offset.x); + for i in (start.line + 1)..end.line { + let line = iter.next().unwrap(); + let offset = vec2(0.0, i as f32 * line_height); + highlight(offset, line.line_w); + } + let offset = vec2(0.0, end.line as f32 * line_height); + highlight(offset, end_offset.x); + } + // painter.primitive_within( + // RectPrimitive::color(Color::WHITE), + // size.align(Align::TOP_LEFT) + // .offset(end_offset) + // .within(®ion), + // ); + let size = vec2(1, self.attrs.line_height); + painter.primitive_within( + RectPrimitive::color(Color::WHITE), + size.align(Align::TOP_LEFT) + .offset(end_offset) + .within(®ion), + ); + } + } } } @@ -56,17 +114,17 @@ impl Widget for TextEdit { } /// copied & modified from fn found in Editor in cosmic_text -fn cursor_pos(cursor: &Cursor, buf: &TextBuffer) -> Option<(f32, f32)> { +fn cursor_pos(cursor: &Cursor, buf: &TextBuffer) -> Option { let mut prev = None; for run in buf .layout_runs() .skip_while(|r| r.line_i < cursor.line) .take_while(|r| r.line_i == cursor.line) { - prev = Some((run.line_w, run.line_top)); + prev = Some(vec2(run.line_w, run.line_top)); for glyph in run.glyphs.iter() { if cursor.index == glyph.start { - return Some((glyph.x, run.line_top)); + return Some(vec2(glyph.x, run.line_top)); } else if cursor.index > glyph.start && cursor.index < glyph.end { // Guess x offset based on characters let mut before = 0; @@ -81,7 +139,7 @@ fn cursor_pos(cursor: &Cursor, buf: &TextBuffer) -> Option<(f32, f32)> { } let offset = glyph.w * (before as f32) / (total as f32); - return Some((glyph.x + offset, run.line_top)); + return Some(vec2(glyph.x + offset, run.line_top)); } } } @@ -106,22 +164,18 @@ impl<'a> TextEditCtx<'a> { self.text .buf .set_text(self.font_system, "", &Attrs::new(), SHAPING, None); - if let Some(cursor) = &mut self.text.cursor { - cursor.line = 0; - cursor.index = 0; - cursor.affinity = Affinity::default(); - } + self.text.selection.clear(); text } pub fn motion(&mut self, motion: Motion) { - if let Some(cursor) = self.text.cursor + if let TextSelection::Pos(cursor) = self.text.selection && let Some((cursor, _)) = self.text .buf .cursor_motion(self.font_system, cursor, None, motion) { - self.text.cursor = Some(cursor); + self.text.selection = TextSelection::Pos(cursor); } } @@ -144,12 +198,41 @@ impl<'a> TextEditCtx<'a> { } } + pub fn clear_span(&mut self) -> bool { + if let TextSelection::Span { start, end } = self.text.selection { + let lines = &mut self.text.view.buf.lines; + let (start, end) = sort_cursors(start, end); + if start.line == end.line { + let line = &mut lines[start.line]; + let text = line.text(); + let text = text[..start.index].to_string() + &text[end.index..]; + edit_line(line, text); + } else { + // start + let start_text = lines[start.line].text()[..start.index].to_string(); + let end_text = &lines[end.line].text()[end.index..]; + let text = start_text + end_text; + edit_line(&mut lines[start.line], text); + } + // between + let range = (start.line + 1)..=end.line; + if !range.is_empty() { + lines.splice(range, None); + } + self.text.selection = TextSelection::Pos(start); + true + } else { + false + } + } + fn insert_inner(&mut self, text: &str, mov: bool) { - if let Some(cursor) = &mut self.text.cursor { + self.clear_span(); + if let TextSelection::Pos(cursor) = &mut self.text.selection { let line = &mut self.text.view.buf.lines[cursor.line]; let mut line_text = line.text().to_string(); line_text.insert_str(cursor.index, text); - line.set_text(line_text, line.ending(), line.attrs_list().clone()); + edit_line(line, line_text); if mov { for _ in 0..text.chars().count() { self.motion(Motion::Right); @@ -159,7 +242,8 @@ impl<'a> TextEditCtx<'a> { } pub fn newline(&mut self) { - if let Some(cursor) = &mut self.text.cursor { + self.clear_span(); + if let TextSelection::Pos(cursor) = &mut self.text.selection { let lines = &mut self.text.view.buf.lines; let line = &mut lines[cursor.line]; let new = line.split_off(cursor.index); @@ -170,7 +254,8 @@ impl<'a> TextEditCtx<'a> { } pub fn backspace(&mut self) { - if let Some(cursor) = &mut self.text.cursor + if !self.clear_span() + && let TextSelection::Pos(cursor) = &mut self.text.selection && (cursor.index != 0 || cursor.line != 0) { self.motion(Motion::Left); @@ -179,7 +264,9 @@ impl<'a> TextEditCtx<'a> { } pub fn delete(&mut self) { - if let Some(cursor) = &mut self.text.cursor { + if !self.clear_span() + && let TextSelection::Pos(cursor) = &mut self.text.selection + { let lines = &mut self.text.view.buf.lines; let line = &mut lines[cursor.line]; if cursor.index == line.text().len() { @@ -190,22 +277,47 @@ impl<'a> TextEditCtx<'a> { let line = &mut lines[cursor.line]; let mut cur = line.text().to_string(); cur.push_str(&add); - line.set_text(cur, line.ending(), line.attrs_list().clone()); + edit_line(line, cur); } else { let mut text = line.text().to_string(); text.remove(cursor.index); - line.set_text(text, line.ending(), line.attrs_list().clone()); + edit_line(line, text); } } } - pub fn select(&mut self, pos: Vec2, size: Vec2) { + pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool) { let pos = pos - self.text.region().top_left().to_abs(size); - self.text.cursor = self.text.buf.hit(pos.x, pos.y); + let hit = self.text.buf.hit(pos.x, pos.y); + let sel = &mut self.text.selection; + match sel { + TextSelection::None => { + if !drag && let Some(hit) = hit { + *sel = TextSelection::Pos(hit) + } + } + TextSelection::Pos(pos) => match (hit, drag) { + (None, false) => *sel = TextSelection::None, + (None, true) => (), + (Some(hit), false) => *pos = hit, + (Some(end), true) => *sel = TextSelection::Span { start: *pos, end }, + }, + TextSelection::Span { start, end } => match (hit, drag) { + (None, false) => *sel = TextSelection::None, + (None, true) => *sel = TextSelection::Pos(*start), + (Some(hit), false) => *sel = TextSelection::Pos(hit), + (Some(hit), true) => *end = hit, + }, + } + if let TextSelection::Span { start, end } = sel + && start == end + { + *sel = TextSelection::Pos(*start); + } } pub fn deselect(&mut self) { - self.text.cursor = None; + self.text.selection = TextSelection::None; } pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { @@ -232,11 +344,20 @@ impl<'a> TextEditCtx<'a> { _ => return TextInputResult::Unused, }, Key::Character(text) => { - if modifiers.control && text == "v" { - return TextInputResult::Paste; - } else { - self.insert(text) + if modifiers.control { + match text.as_str() { + "v" => return TextInputResult::Paste, + "c" => { + if let TextSelection::Span { start, end } = self.text.selection { + let content = self.text.select_content(start, end); + return TextInputResult::Copy(content); + } + return TextInputResult::Used; + } + _ => (), + } } + self.insert(text) } _ => return TextInputResult::Unused, } @@ -262,9 +383,37 @@ pub enum TextInputResult { Unused, Unfocus, Submit, + Copy(String), Paste, } +#[derive(Default)] +pub enum TextSelection { + #[default] + None, + Pos(Cursor), + Span { + start: Cursor, + end: Cursor, + }, +} + +impl TextSelection { + pub fn clear(&mut self) { + match self { + TextSelection::None => (), + TextSelection::Pos(cursor) => { + cursor.line = 0; + cursor.index = 0; + cursor.affinity = Affinity::default(); + } + TextSelection::Span { start: _, end: _ } => { + *self = TextSelection::None; + } + } + } +} + impl TextInputResult { pub fn unfocus(&self) -> bool { matches!(self, TextInputResult::Unfocus) diff --git a/src/core/text/mod.rs b/src/core/text/mod.rs index 6484b78..bcd471f 100644 --- a/src/core/text/mod.rs +++ b/src/core/text/mod.rs @@ -5,7 +5,7 @@ pub use build::*; pub use edit::*; use crate::{prelude::*, util::MutDetect}; -use cosmic_text::{Attrs, Metrics, Shaping}; +use cosmic_text::{Attrs, BufferLine, Cursor, Metrics, Shaping}; use std::ops::{Deref, DerefMut}; pub const SHAPING: Shaping = Shaping::Advanced; @@ -82,6 +82,15 @@ impl TextView { painter.texture_within(&tex.handle, region); region } + + pub fn content(&self) -> String { + self.buf + .lines + .iter() + .map(|l| l.text()) + .collect::>() + .join("\n") + } } impl Text { @@ -124,6 +133,16 @@ impl Widget for Text { } } +pub fn sort_cursors(a: Cursor, b: Cursor) -> (Cursor, Cursor) { + let start = a.min(b); + let end = a.max(b); + (start, end) +} + +pub fn edit_line(line: &mut BufferLine, text: String) { + line.set_text(text, line.ending(), line.attrs_list().clone()); +} + impl Deref for Text { type Target = TextAttrs; diff --git a/src/layout/attr.rs b/src/layout/attr.rs new file mode 100644 index 0000000..5c55866 --- /dev/null +++ b/src/layout/attr.rs @@ -0,0 +1,20 @@ +use crate::layout::{Ui, WidgetId, WidgetIdFn, WidgetLike}; + +pub trait WidgetAttr { + type Input; + fn run(ui: &mut Ui, id: &WidgetId, input: Self::Input); +} + +pub trait Attrable { + fn attr>(self, input: A::Input) -> impl WidgetIdFn; +} + +impl, Tag> Attrable for WL { + fn attr>(self, input: A::Input) -> impl WidgetIdFn { + |ui| { + let id = self.add(ui); + A::run(ui, &id, input); + id + } + } +} diff --git a/src/layout/event.rs b/src/layout/event.rs index 974a27b..c74a516 100644 --- a/src/layout/event.rs +++ b/src/layout/event.rs @@ -55,10 +55,7 @@ impl, Tag> Eventable for W { ) -> impl WidgetIdFn { move |ui| { let id = self.add(ui); - ui.data - .modules - .get_mut::>() - .register(id.id, event, f); + ui.register_event(&id, event, f); id } } diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 652ece3..0e13b5c 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -9,6 +9,7 @@ mod painter; mod text; mod texture; mod ui; +mod attr; mod vec2; mod widget; mod widgets; @@ -24,6 +25,7 @@ pub use painter::*; pub use text::*; pub use texture::*; pub use ui::*; +pub use attr::*; pub use vec2::*; pub use widget::*; pub use widgets::*; diff --git a/src/layout/painter.rs b/src/layout/painter.rs index 0ed875d..100a0f4 100644 --- a/src/layout/painter.rs +++ b/src/layout/painter.rs @@ -17,8 +17,6 @@ pub struct Painter<'a, 'c> { children: Vec, children_width: HashMap, children_height: HashMap, - /// whether this widget depends on region's final pixel size or not - /// TODO: decide if point (pt) should be used here instead of px pub layer: usize, id: Id, } diff --git a/src/layout/ui.rs b/src/layout/ui.rs index 1fe05ce..127abd6 100644 --- a/src/layout/ui.rs +++ b/src/layout/ui.rs @@ -3,8 +3,8 @@ use image::DynamicImage; use crate::{ core::{TextEdit, TextEditCtx}, layout::{ - IdLike, PainterData, PixelRegion, StaticWidgetId, TextureHandle, Vec2, Widget, - WidgetId, WidgetInstance, WidgetLike, + Event, EventFn, EventModule, IdLike, PainterData, PixelRegion, StaticWidgetId, + TextureHandle, Vec2, Widget, WidgetId, WidgetInstance, WidgetLike, }, util::{HashSet, Id}, }; @@ -96,6 +96,18 @@ impl Ui { self.data.textures.add(image) } + pub fn register_event( + &mut self, + id: &WidgetId, + event: E, + f: impl EventFn, + ) { + self.data + .modules + .get_mut::>() + .register(id.id, event, f); + } + pub fn resize(&mut self, size: impl Into) { self.data.output_size = size.into(); self.resized = true;