Compare commits

1 Commits

Author SHA1 Message Date
1cec56e847 TEXT SELECTION 2025-11-21 23:56:31 -05:00
10 changed files with 332 additions and 69 deletions

View File

@@ -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<Window>,
input: Input,
ui: Ui,
info: WidgetId<Text>,
focus: Option<WidgetId<TextEdit>>,
clipboard: Clipboard,
ime: usize,
}
#[derive(Eq, PartialEq, Hash, Clone)]
@@ -32,9 +39,37 @@ impl DefaultEvent for Submit {
type Data = ();
}
pub struct Selectable;
impl WidgetAttr<TextEdit> for Selectable {
type Input = ();
fn run(ui: &mut Ui, id: &WidgetId<TextEdit>, _: 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::<f32>::from(region.top_left.tuple()),
LogicalSize::<f32>::from(region.size().tuple()),
);
}
client.focus = Some(id.clone());
},
);
}
}
impl Client {
pub fn new(window: Arc<Window>) -> 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::<Selectable>(())
.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::<Selectable>(());
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!(

View File

@@ -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<Ctx> {
@@ -188,11 +197,12 @@ impl<Ctx: UiCtx + 'static> CursorModule<Ctx> {
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<Ctx: UiCtx + 'static> CursorModule<Ctx> {
}
}
pub fn should_run(senses: &CursorSenses, cursor: &CursorState, hover: ActivationState) -> bool {
pub fn should_run(
senses: &CursorSenses,
cursor: &CursorState,
hover: ActivationState,
) -> Option<CursorSense> {
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<E: Event<Data = <CursorSenses as Event>::Data> + Into<CursorSenses>, Ctx: 'static> EventModule<E, Ctx>
for CursorModule<Ctx>
impl<E: Event<Data = <CursorSenses as Event>::Data> + Into<CursorSenses>, Ctx: 'static>
EventModule<E, Ctx> for CursorModule<Ctx>
{
fn register(&mut self, id: Id, senses: E, f: impl EventFn<Ctx, <E as Event>::Data>) {
// TODO: does not add to active if currently active

View File

@@ -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

View File

@@ -10,7 +10,7 @@ use winit::{
pub struct TextEdit {
pub(super) view: TextView,
pub(super) cursor: Option<Cursor>,
pub(super) selection: TextSelection,
}
impl TextEdit {
@@ -21,23 +21,35 @@ impl TextEdit {
.align(self.align)
}
pub fn content(&self) -> String {
self.buf
.lines
.iter()
.map(|l| l.text())
.collect::<Vec<_>>()
.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)
{
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),
@@ -45,6 +57,52 @@ impl Widget for TextEdit {
);
}
}
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(&region),
);
};
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(&region),
// );
let size = vec2(1, self.attrs.line_height);
painter.primitive_within(
RectPrimitive::color(Color::WHITE),
size.align(Align::TOP_LEFT)
.offset(end_offset)
.within(&region),
);
}
}
}
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
self.view.desired_width(ctx)
@@ -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<Vec2> {
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)

View File

@@ -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::<Vec<_>>()
.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;

20
src/layout/attr.rs Normal file
View File

@@ -0,0 +1,20 @@
use crate::layout::{Ui, WidgetId, WidgetIdFn, WidgetLike};
pub trait WidgetAttr<W> {
type Input;
fn run(ui: &mut Ui, id: &WidgetId<W>, input: Self::Input);
}
pub trait Attrable<W, Tag> {
fn attr<A: WidgetAttr<W>>(self, input: A::Input) -> impl WidgetIdFn<W>;
}
impl<WL: WidgetLike<Tag>, Tag> Attrable<WL::Widget, Tag> for WL {
fn attr<A: WidgetAttr<WL::Widget>>(self, input: A::Input) -> impl WidgetIdFn<WL::Widget> {
|ui| {
let id = self.add(ui);
A::run(ui, &id, input);
id
}
}
}

View File

@@ -55,10 +55,7 @@ impl<W: WidgetLike<Tag>, Tag> Eventable<W::Widget, Tag> for W {
) -> impl WidgetIdFn<W::Widget> {
move |ui| {
let id = self.add(ui);
ui.data
.modules
.get_mut::<E::Module<Ctx>>()
.register(id.id, event, f);
ui.register_event(&id, event, f);
id
}
}

View File

@@ -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::*;

View File

@@ -17,8 +17,6 @@ pub struct Painter<'a, 'c> {
children: Vec<Id>,
children_width: HashMap<Id, (UiVec2, Len)>,
children_height: HashMap<Id, (UiVec2, Len)>,
/// 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,
}

View File

@@ -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<W, E: Event, Ctx: 'static>(
&mut self,
id: &WidgetId<W>,
event: E,
f: impl EventFn<Ctx, E::Data>,
) {
self.data
.modules
.get_mut::<E::Module<Ctx>>()
.register(id.id, event, f);
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.data.output_size = size.into();
self.resized = true;