From c0541d7c7d82e4abea8e7d6f080a08d0cfe15cdd Mon Sep 17 00:00:00 2001 From: kokoISnoTarget <72217393+kokoISnoTarget@users.noreply.github.com> Date: Sat, 13 Apr 2024 05:35:01 +0200 Subject: [PATCH] Add double and triple click selection (#295) * refactor: move selection things out of the renderer into its own struct * refactor+feat: Move even more out of the renderer and add basic double and triple clicking This is still realy broken * fix: moved the logic to get the text behind an condition and could therefore remove some complexity * fmt and adding selection for summarys * fmt and adding selection for summarys chore: removed an debug message * Add must This is added so we don't forget to redraw the window Co-authored-by: CosmicHorror * Remove leftover comments * Use more fitting types for durations * Removed useless check * fix: add check so double clicking whitespace doesn't select it and an check for the case cursor.index.. would retrun more than desired * fmt --------- Co-authored-by: CosmicHorror --- src/main.rs | 46 +++++------ src/renderer.rs | 80 +++++++++---------- src/selection.rs | 111 +++++++++++++++++++++++++++ src/text.rs | 194 ++++++++++++++++++++++++++++++++--------------- src/utils.rs | 6 +- 5 files changed, 304 insertions(+), 133 deletions(-) create mode 100644 src/selection.rs diff --git a/src/main.rs b/src/main.rs index 7e0c0d65..49b221bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ pub mod opts; mod panic_hook; pub mod positioner; pub mod renderer; +pub mod selection; pub mod table; pub mod test_utils; pub mod text; @@ -56,6 +57,7 @@ use tracing_subscriber::util::SubscriberInitExt; use utils::{ImageCache, Point, Rect, Size}; use crate::opts::{Commands, ConfigCmd, MetricsExporter}; +use crate::selection::Selection; use anyhow::Context; use clap::Parser; use taffy::Taffy; @@ -147,6 +149,7 @@ pub struct Inlyne { keycombos: KeyCombos, need_repositioning: bool, watcher: Watcher, + selection: Selection, } impl Inlyne { @@ -221,6 +224,7 @@ impl Inlyne { keycombos, need_repositioning: false, watcher, + selection: Selection::new(), }) } @@ -280,9 +284,7 @@ impl Inlyne { let mut scrollbar_held = None; let mut mouse_down = false; let mut modifiers = ModifiersState::empty(); - let mut last_loc = (0.0, 0.0); - let mut selection_cache = String::new(); - let mut selecting = false; + let mut mouse_position: Point = Point::default(); let event_loop = self.event_loop.take().unwrap(); let event_loop_proxy = event_loop.create_proxy(); @@ -330,12 +332,9 @@ impl Inlyne { ); self.renderer.set_scroll_y(self.renderer.scroll_y); self.renderer - .redraw(&mut self.elements) + .redraw(&mut self.elements, &mut self.selection) .context("Renderer failed to redraw the screen") .unwrap(); - if selecting { - selection_cache.clone_from(&self.renderer.selection_text); - } histogram!(HistTag::Redraw).record(redraw_start.elapsed()); } @@ -426,14 +425,10 @@ impl Inlyne { * self.renderer.positioner.reserved_height; self.renderer.set_scroll_y(target_scroll); self.window.request_redraw(); - } else if let Some(selection) = &mut self.renderer.selection { - if mouse_down { - selection.1 = loc; - selecting = true; - self.window.request_redraw(); - } + } else if mouse_down && self.selection.handle_drag(loc) { + self.window.request_redraw(); } - last_loc = loc; + mouse_position = loc; } WindowEvent::MouseInput { state, @@ -441,19 +436,13 @@ impl Inlyne { .. } => match state { ElementState::Pressed => { - // Reset selection - if self.renderer.selection.is_some() { - self.renderer.selection = None; - self.window.request_redraw(); - } - // Try to click a link let screen_size = self.renderer.screen_size(); if let Some(hoverable) = Self::find_hoverable( &mut self.renderer.text_system, &mut self.renderer.positioner.taffy, &self.elements, - last_loc, + mouse_position, screen_size, self.renderer.zoom, ) { @@ -504,20 +493,21 @@ impl Inlyne { event_loop_proxy .send_event(InlyneEvent::Reposition) .unwrap(); - self.renderer.selection = Some((last_loc, last_loc)) + self.selection.add_position(mouse_position); }, - _ => self.renderer.selection = Some((last_loc, last_loc)), + _ => { + self.selection.add_position(mouse_position); + self.window.request_redraw(); + } }; - } else if self.renderer.selection.is_none() { - self.renderer.selection = Some((last_loc, last_loc)); + } else { + self.selection.add_position(mouse_position); } - mouse_down = true; } ElementState::Released => { scrollbar_held = None; mouse_down = false; - selecting = false; } }, WindowEvent::ModifiersChanged(new_state) => modifiers = new_state, @@ -587,7 +577,7 @@ impl Inlyne { self.window.request_redraw(); } Action::Copy => clipboard - .set_contents(selection_cache.trim().to_owned()), + .set_contents(self.selection.text.trim().to_owned()), Action::Quit => *control_flow = ControlFlow::Exit, Action::History(hist_dir) => { let changed_path = match hist_dir { diff --git a/src/renderer.rs b/src/renderer.rs index f098c0a6..5661c619 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -8,9 +8,10 @@ use crate::image::ImageRenderer; use crate::metrics::{histogram, HistTag}; use crate::opts::FontOptions; use crate::positioner::{Positioned, Positioner, DEFAULT_MARGIN}; +use crate::selection::Selection; use crate::table::TABLE_ROW_GAP; use crate::text::{CachedTextArea, TextCache, TextSystem}; -use crate::utils::{Point, Rect, Selection, Size}; +use crate::utils::{Point, Rect, Size}; use crate::Element; use anyhow::{Context, Ok}; @@ -45,8 +46,6 @@ pub struct Renderer { pub page_width: f32, pub image_renderer: ImageRenderer, pub theme: Theme, - pub selection: Option, - pub selection_text: String, pub zoom: f32, pub positioner: Positioner, } @@ -186,8 +185,6 @@ impl Renderer { zoom: 1., image_renderer, theme, - selection: None, - selection_text: String::new(), positioner, }) } @@ -211,6 +208,7 @@ impl Renderer { fn render_elements( &mut self, elements: &[Positioned], + selection: &mut Selection, ) -> anyhow::Result> { let mut text_areas: Vec = Vec::new(); let screen_size = self.screen_size(); @@ -337,16 +335,13 @@ impl Renderer { let max = (line.max.0, line.max.1 + 2. * self.hidpi_scale * self.zoom); self.draw_rectangle(Rect::from_min_max(min, max), line.color)?; } - if let Some(selection) = self.selection { - let (selection_rects, selection_text) = text_box.render_selection( - &mut self.text_system, - pos, - bounds, - self.zoom, - selection, - ); - self.selection_text.push_str(&selection_text); - self.selection_text.push('\n'); + if let Some(selection_rects) = text_box.render_selection( + &mut self.text_system, + pos, + bounds, + self.zoom, + selection, + ) { for rect in selection_rects { self.draw_rectangle( Rect::from_min_max( @@ -379,16 +374,13 @@ impl Renderer { self.zoom, self.scroll_y, )); - if let Some(selection) = self.selection { - let (selection_rects, selection_text) = text_box.render_selection( - &mut self.text_system, - (pos.0 + node.location.x, pos.1 + node.location.y), - (node.size.width, node.size.height), - self.zoom, - selection, - ); - self.selection_text.push_str(&selection_text); - self.selection_text.push('\n'); + if let Some(selection_rects) = text_box.render_selection( + &mut self.text_system, + (pos.0 + node.location.x, pos.1 + node.location.y), + (node.size.width, node.size.height), + self.zoom, + selection, + ) { for rect in selection_rects { self.draw_rectangle( Rect::from_min_max( @@ -442,17 +434,13 @@ impl Renderer { self.scroll_y, )); - if let Some(selection) = self.selection { - let (selection_rects, selection_text) = text_box - .render_selection( - &mut self.text_system, - (pos.0 + node.location.x, pos.1 + node.location.y), - (node.size.width, node.size.height), - self.zoom, - selection, - ); - self.selection_text.push_str(&selection_text); - self.selection_text.push('\n'); + if let Some(selection_rects) = text_box.render_selection( + &mut self.text_system, + (pos.0 + node.location.x, pos.1 + node.location.y), + (node.size.width, node.size.height), + self.zoom, + selection, + ) { for rect in selection_rects { self.draw_rectangle( Rect::from_min_max( @@ -512,7 +500,9 @@ impl Renderer { )?; } } - Element::Row(row) => text_areas.append(&mut self.render_elements(&row.elements)?), + Element::Row(row) => { + text_areas.append(&mut self.render_elements(&row.elements, selection)?) + } Element::Section(section) => { if let Some(ref summary) = *section.summary { let bounds = summary.bounds.as_ref().unwrap(); @@ -525,15 +515,16 @@ impl Renderer { native_color(self.theme.text_color, &self.surface_format), *section.hidden.borrow(), )?; - text_areas.append(&mut self.render_elements(std::slice::from_ref(summary))?) + text_areas.append( + &mut self.render_elements(std::slice::from_ref(summary), selection)?, + ) } if !*section.hidden.borrow() { - text_areas.append(&mut self.render_elements(§ion.elements)?) + text_areas.append(&mut self.render_elements(§ion.elements, selection)?) } } } } - self.draw_scrollbar()?; Ok(text_areas) } @@ -721,7 +712,11 @@ impl Renderer { bind_groups } - pub fn redraw(&mut self, elements: &mut [Positioned]) -> anyhow::Result<()> { + pub fn redraw( + &mut self, + elements: &mut [Positioned], + selection: &mut Selection, + ) -> anyhow::Result<()> { let frame = self .surface .get_current_texture() @@ -736,8 +731,7 @@ impl Renderer { // Prepare and render elements that use lyon self.lyon_buffer.indices.clear(); self.lyon_buffer.vertices.clear(); - self.selection_text = String::new(); - let cached_text_areas = self.render_elements(elements)?; + let cached_text_areas = self.render_elements(elements, selection)?; let vertex_buf = self .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 00000000..78287851 --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,111 @@ +use crate::utils::{dist_between_points, Point}; +use std::time::{Duration, Instant}; + +const CLICK_TOLERANCE: Duration = Duration::from_millis(300); +const MAX_CLICK_DIST: f32 = 5.0; + +#[derive(PartialEq, Debug)] +pub enum SelectionMode { + Word, + Line, +} + +#[derive(Debug)] +pub enum SelectionKind { + Drag { + start: Point, + end: Point, + }, + Click { + mode: SelectionMode, + time: Instant, + position: Point, + }, + Start { + position: Point, + time: Instant, + }, + None, +} + +pub struct Selection { + pub selection: SelectionKind, + pub text: String, +} +impl Selection { + pub const fn new() -> Self { + Self { + selection: SelectionKind::None, + text: String::new(), + } + } + pub fn is_none(&self) -> bool { + matches!(self.selection, SelectionKind::None) + } + pub fn start(&mut self, position: Point) { + self.selection = SelectionKind::Start { + position, + time: Instant::now(), + } + } + + #[must_use] + pub fn handle_drag(&mut self, new_position: Point) -> bool { + self.text.clear(); + match &mut self.selection { + SelectionKind::Start { position, .. } => { + self.selection = SelectionKind::Drag { + start: *position, + end: new_position, + }; + } + SelectionKind::Drag { end, .. } => *end = new_position, + _ => return false, + } + true + } + + pub fn add_position(&mut self, new_position: Point) { + self.text.clear(); + + match &self.selection { + SelectionKind::Click { + mode, + time, + position, + } => { + if mode == &SelectionMode::Word + && time.elapsed() < CLICK_TOLERANCE + && dist_between_points(position, &new_position) < MAX_CLICK_DIST + { + self.selection = SelectionKind::Click { + time: Instant::now(), + mode: SelectionMode::Line, + position: new_position, + }; + } else { + self.start(new_position) + } + } + SelectionKind::Start { position, time } => { + if time.elapsed() < CLICK_TOLERANCE + && dist_between_points(position, &new_position) < MAX_CLICK_DIST + { + self.selection = SelectionKind::Click { + time: Instant::now(), + mode: SelectionMode::Word, + position: new_position, + }; + } else { + self.start(new_position) + } + } + _ => self.start(new_position), + } + } + + pub fn add_line(&mut self, str: &str) { + self.text.push_str(str); + self.text.push('\n'); + } +} diff --git a/src/text.rs b/src/text.rs index 80ca56db..4d1312c2 100644 --- a/src/text.rs +++ b/src/text.rs @@ -5,9 +5,6 @@ use std::hash::{BuildHasher, Hash, Hasher}; use std::ops::Range; use std::sync::{Arc, Mutex}; -use crate::debug_impls::{self, DebugInline, DebugInlineMaybeF32Color}; -use crate::utils::{Align, Line, Point, Rect, Selection, Size}; - use fxhash::{FxHashMap, FxHashSet}; use glyphon::{ Affinity, Attrs, AttrsList, BufferLine, Color, Cursor, FamilyOwned, FontSystem, LayoutGlyph, @@ -16,6 +13,10 @@ use glyphon::{ use smart_debug::SmartDebug; use taffy::prelude::{AvailableSpace, Size as TaffySize}; +use crate::debug_impls::{self, DebugInline, DebugInlineMaybeF32Color}; +use crate::selection::{Selection, SelectionKind, SelectionMode}; +use crate::utils::{Align, Line, Point, Rect, Size}; + type KeyHash = u64; type HashBuilder = twox_hash::RandomXxHashBuilder64; @@ -413,16 +414,8 @@ impl TextBox { screen_position: Point, bounds: Size, zoom: f32, - selection: Selection, - ) -> (Vec, String) { - let (mut select_start, mut select_end) = selection; - if select_start.1 > select_end.1 || select_start.0 > select_end.0 { - std::mem::swap(&mut select_start, &mut select_end); - } - if screen_position.1 > select_end.1 || screen_position.1 + bounds.1 < select_start.1 { - return (vec![], String::new()); - } - + selection: &mut Selection, + ) -> Option> { let mut rects = Vec::new(); let mut selected_text = String::new(); @@ -434,65 +427,144 @@ impl TextBox { self.key(bounds, zoom), ); - if let Some(start_cursor) = buffer.hit( - select_start.0 - screen_position.0, - select_start.1 - screen_position.1, - ) { - if let Some(end_cursor) = buffer.hit( - select_end.0 - screen_position.0, - select_end.1 - screen_position.1, - ) { - if start_cursor.index == end_cursor.index { - return (vec![], String::new()); + let (start_cursor, end_cursor, start_y, end_y) = match &selection.selection { + SelectionKind::Drag { mut start, mut end } => { + if start.1 > end.1 || start.0 > end.0 { + std::mem::swap(&mut start, &mut end); + } + if screen_position.1 > end.1 || screen_position.1 + bounds.1 < start.1 { + return None; } - let mut y = screen_position.1; - for line in buffer.layout_runs() { - let line_contains = - move |y_point: f32| y_point >= y && y_point <= y + line_height; - if line_contains(select_start.1) - || line_contains(select_end.1) - || (select_start.1 < y && select_end.1 > y + line_height) - { - if let Some((highlight_x, highlight_w)) = - line.highlight(start_cursor, end_cursor) - { - let x = screen_position.0 + highlight_x; - rects.push(Rect::from_min_max( - (x.floor(), y), - ((x + highlight_w).ceil(), y + line_height), - )); + let start_cursor = + buffer.hit(start.0 - screen_position.0, start.1 - screen_position.1)?; + let end_cursor = + buffer.hit(end.0 - screen_position.0, end.1 - screen_position.1)?; + (start_cursor, end_cursor, start.1, end.1) + } + SelectionKind::Click { mode, position, .. } => { + let mut cursor = buffer.hit( + position.0 - screen_position.0, + position.1 - screen_position.1, + )?; + + let line = buffer.lines.get(cursor.line)?; + + match mode { + SelectionMode::Word => { + let text = line.text(); + + let mut start_index = None; + let mut end_index = None; + + match cursor.affinity { + Affinity::Before => { + if cursor.index == 0 { + return None; + } + if text + .get(cursor.index - 1..cursor.index)? + .contains(|c: char| c.is_whitespace()) + { + cursor.index += 1; + } else if cursor.index == text.len() + || text + .get(cursor.index..cursor.index + 1)? + .contains(|c: char| c.is_whitespace()) + { + end_index = Some(cursor.index); + } + } + Affinity::After => { + if text + .get(cursor.index..cursor.index + 1)? + .contains(|c: char| c.is_whitespace()) + { + cursor.index -= 1; + } else if cursor.index == 0 + || text + .get(cursor.index - 1..cursor.index)? + .contains(|c: char| c.is_whitespace()) + { + start_index = Some(cursor.index) + } + } } - } - // See https://docs.rs/cosmic-text/0.8.0/cosmic_text/struct.LayoutRun.html#method.highlight implementation - for glyph in line.glyphs.iter() { - let left_glyph_cursor = if line.rtl { - Cursor::new_with_affinity(line.line_i, glyph.end, Affinity::Before) - } else { - Cursor::new_with_affinity(line.line_i, glyph.start, Affinity::After) - }; - let right_glyph_cursor = if line.rtl { - Cursor::new_with_affinity(line.line_i, glyph.start, Affinity::After) - } else { - Cursor::new_with_affinity(line.line_i, glyph.end, Affinity::Before) - }; - if (left_glyph_cursor >= start_cursor && left_glyph_cursor <= end_cursor) - && (right_glyph_cursor >= start_cursor - && right_glyph_cursor <= end_cursor) - { - selected_text.push_str(&line.text[glyph.start..glyph.end]); + if end_index.is_none() { + let end_text = text + .get(cursor.index..) + .and_then(|str| str.split_whitespace().next())?; + end_index = Some(end_text.len() + cursor.index); } + if start_index.is_none() { + let start_text = text + .get(..cursor.index) + .and_then(|str| str.split_whitespace().next_back())?; + start_index = Some(cursor.index - start_text.len()); + } + + let start = + Cursor::new(cursor.line, start_index.expect("Should have an value")); + let end = + Cursor::new(cursor.line, end_index.expect("Should have an value")); + + (start, end, position.1, position.1) } - if select_end.1 > y + line_height { - selected_text.push(' ') + SelectionMode::Line => { + let start = Cursor::new(cursor.line, 0); + let end = Cursor::new(cursor.line, line.text().len()); + (start, end, position.1, position.1) } - y += line_height; } } + _ => { + return None; + } + }; + + let mut y = screen_position.1; + for line in buffer.layout_runs() { + let line_contains = move |y_point: f32| y_point >= y && y_point <= y + line_height; + if line_contains(start_y) + || line_contains(end_y) + || (start_y < y && end_y > y + line_height) + { + if let Some((highlight_x, highlight_w)) = line.highlight(start_cursor, end_cursor) { + let x = screen_position.0 + highlight_x; + rects.push(Rect::from_min_max( + (x.floor(), y), + ((x + highlight_w).ceil(), y + line_height), + )); + } + // See https://docs.rs/cosmic-text/0.8.0/cosmic_text/struct.LayoutRun.html#method.highlight implementation + for glyph in line.glyphs.iter() { + let left_glyph_cursor = if line.rtl { + Cursor::new_with_affinity(line.line_i, glyph.end, Affinity::Before) + } else { + Cursor::new_with_affinity(line.line_i, glyph.start, Affinity::After) + }; + let right_glyph_cursor = if line.rtl { + Cursor::new_with_affinity(line.line_i, glyph.start, Affinity::After) + } else { + Cursor::new_with_affinity(line.line_i, glyph.end, Affinity::Before) + }; + if (left_glyph_cursor >= start_cursor && left_glyph_cursor <= end_cursor) + && (right_glyph_cursor >= start_cursor && right_glyph_cursor <= end_cursor) + { + selected_text.push_str(&line.text[glyph.start..glyph.end]); + } + } + if end_y > y + line_height { + selected_text.push(' ') + } + } + y += line_height; } - (rects, selected_text) + selection.add_line(&selected_text); + + Some(rects) } } diff --git a/src/utils.rs b/src/utils.rs index d5bf39d1..42b55355 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -62,8 +62,12 @@ pub fn usize_in_mib(num: usize) -> f32 { num as f32 / 1_024.0 / 1_024.0 } -pub type Selection = ((f32, f32), (f32, f32)); pub type Point = (f32, f32); + +pub fn dist_between_points(p1: &Point, p2: &Point) -> f32 { + f32::sqrt((p2.0 - p1.0).powf(2.0) + (p2.1 - p1.1).powf(2.0)) +} + pub type Size = (f32, f32); pub type ImageCache = Arc>>>>>;