diff options
| author | alex-ds13 <145657253+alex-ds13@users.noreply.github.com> | 2025-11-25 14:42:06 +0000 |
|---|---|---|
| committer | Polesznyák Márk <contact@pml68.dev> | 2025-12-29 23:23:41 +0100 |
| commit | b46c556f863917a753c55359fb05543db0ccef7c (patch) | |
| tree | 5e7cd96ef880fd66cfd8ad23bc685d1ffc5cac33 | |
| parent | chore: add missing hint_factor fields (iced update) (diff) | |
| download | iced_selection-b46c556f863917a753c55359fb05543db0ccef7c.tar.gz | |
feat: correct selection on wrapped lines and allow mouse drag out of bounds
| -rw-r--r-- | examples/name/src/main.rs | 3 | ||||
| -rw-r--r-- | src/text.rs | 287 | ||||
| -rw-r--r-- | src/text/rich.rs | 287 |
3 files changed, 263 insertions, 314 deletions
diff --git a/examples/name/src/main.rs b/examples/name/src/main.rs index 9bf4b72..2e2582c 100644 --- a/examples/name/src/main.rs +++ b/examples/name/src/main.rs @@ -1,4 +1,3 @@ -use iced::advanced::text::Wrapping; use iced::widget::operation::focus_next; use iced::widget::{center, column, text_editor}; use iced::{Center, Element, Task}; @@ -41,7 +40,7 @@ impl State { fn view(&self) -> Element<'_, Message> { center( column![ - text!("Hello {}", &self.name).wrapping(Wrapping::None), + text!("Hello {}", &self.name), text_editor(&self.content).on_action(Message::UpdateText) ] .spacing(10) diff --git a/src/text.rs b/src/text.rs index 7f67b86..5e1356f 100644 --- a/src/text.rs +++ b/src/text.rs @@ -19,6 +19,7 @@ mod rich; use iced_widget::graphics::text::Paragraph; +use iced_widget::graphics::text::cosmic_text; pub use rich::Rich; use text::{Alignment, LineHeight, Shaping, Wrapping}; pub use text::{Fragment, Highlighter, IntoFragment, Span}; @@ -189,9 +190,26 @@ pub enum Dragging { } impl State { - fn grapheme_line_and_index(&self, point: Point) -> Option<(usize, usize)> { - let cursor = self.paragraph.buffer().hit(point.x, point.y)?; - let value = self.paragraph.buffer().lines[cursor.line].text(); + fn grapheme_line_and_index( + &self, + point: Point, + bounds: core::Rectangle, + ) -> Option<(usize, usize)> { + let bounded_x = if point.y < bounds.y { + bounds.x + } else if point.y > bounds.y + bounds.height { + bounds.x + bounds.width + } else { + point.x.max(bounds.x).min(bounds.x + bounds.width) + }; + let bounded_y = point.y.max(bounds.y).min(bounds.y + bounds.height); + let bounded_point = Point::new(bounded_x, bounded_y); + let relative_point = + bounded_point - core::Vector::new(bounds.x, bounds.y); + + let buffer = self.paragraph.buffer(); + let cursor = buffer.hit(relative_point.x, relative_point.y)?; + let value = buffer.lines[cursor.line].text(); Some(( cursor.line, @@ -203,119 +221,48 @@ impl State { )) } - fn selection_end_points(&self) -> (usize, Point, Point) { + fn selection(&self) -> Vec<core::Rectangle> { let Selection { start, end, .. } = self.selection; - let (start_row, start_position) = self - .grapheme_position(start.line, start.index) - .unwrap_or_default(); - - let (end_row, end_position) = self - .grapheme_position(end.line, end.index) - .unwrap_or_default(); + let buffer = self.paragraph.buffer(); + let line_height = self.paragraph.buffer().metrics().line_height; + let selected_lines = end.line - start.line + 1; - ( - end_row.saturating_sub(start_row) + 1, - start_position, - end_position, - ) - } + let visual_lines_offset = visual_lines_offset(start.line, buffer); - fn grapheme_position( - &self, - line: usize, - index: usize, - ) -> Option<(usize, Point)> { - use unicode_segmentation::UnicodeSegmentation; - - let mut first_run_index = None; - let mut last_run_index = None; - let mut last_start = None; - let mut last_grapheme_count = 0; - let mut last_run_graphemes = 0; - let mut real_index = 0; - let mut graphemes_seen = 0; - - let mut glyphs = self - .paragraph - .buffer() - .layout_runs() + buffer + .lines + .iter() + .skip(start.line) + .take(selected_lines) .enumerate() - .filter(|(_, run)| run.line_i == line) - .flat_map(|(run_idx, run)| { - let line_top = run.line_top; - - if first_run_index.is_none() { - first_run_index = Some(run_idx); - } - - run.glyphs.iter().map(move |glyph| { - let mut glyph = glyph.clone(); - glyph.y += line_top; - (run_idx, glyph, run.text) - }) - }); - - let (_, glyph, _) = glyphs - .find(|(run_idx, glyph, text)| { - if Some(glyph.start) != last_start { - last_grapheme_count = - text[glyph.start..glyph.end].graphemes(false).count(); - last_start = Some(glyph.start); - graphemes_seen += last_grapheme_count; - last_run_graphemes += last_grapheme_count; - real_index += last_grapheme_count; - - if Some(*run_idx) != last_run_index - && graphemes_seen < index - { - real_index = last_grapheme_count; - last_run_graphemes = last_grapheme_count; - } - } else if Some(*run_idx) != last_run_index - && graphemes_seen < index - { - real_index = 0; - last_run_graphemes = 0; + .flat_map(|(i, line)| { + highlight_line( + line, + if i == 0 { start.index } else { 0 }, + if i == selected_lines - 1 { + end.index + } else { + line.text().len() + }, + ) + }) + .enumerate() + .filter_map(|(visual_line, (x, width))| { + if width > 0.0 { + Some(core::Rectangle { + x, + width, + y: (visual_line as i32 + visual_lines_offset) as f32 + * line_height + - buffer.scroll().vertical, + height: line_height, + }) + } else { + None } - - last_run_index = Some(*run_idx); - - graphemes_seen >= index }) - .or_else(|| glyphs.last())?; - - real_index -= graphemes_seen.saturating_sub(index); - real_index = - real_index.saturating_sub(last_run_index? - first_run_index?); - - last_run_graphemes = last_run_graphemes - .saturating_sub(last_run_index? - first_run_index?); - - let advance = if last_run_index? - first_run_index? <= 1 { - if real_index == 0 { - 0.0 - } else { - glyph.w - * (1.0 - - last_run_graphemes.saturating_sub(real_index) as f32 - / last_grapheme_count.max(1) as f32) - - glyph.w * (last_run_index? - first_run_index?) as f32 - } - } else { - -(glyph.w - * (1.0 - + last_run_graphemes.saturating_sub(real_index) as f32 - / last_grapheme_count.max(1) as f32)) - }; - - Some(( - last_run_index?, - Point::new( - glyph.x + glyph.x_offset * glyph.font_size + advance, - glyph.y - glyph.y_offset * glyph.font_size, - ), - )) + .collect() } fn update(&mut self, text: text::Text<&str, Font>) { @@ -401,7 +348,7 @@ where match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { - if let Some(position) = click_position { + if let Some(position) = cursor.position_over(bounds) { let click = mouse::Click::new( position, mouse::Button::Left, @@ -409,7 +356,7 @@ where ); let (line, index) = state - .grapheme_line_and_index(position) + .grapheme_line_and_index(position, bounds) .unwrap_or((0, 0)); match click.kind() { @@ -453,11 +400,11 @@ where } Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { - if let Some(position) = click_position + if let Some(position) = cursor.position() && let Some(dragging) = state.dragging { let (line, index) = state - .grapheme_line_and_index(position) + .grapheme_line_and_index(position, bounds) .unwrap_or((0, 0)); match dragging { @@ -634,51 +581,17 @@ where if !state.selection.is_empty() { let bounds = layout.bounds(); + let translation = bounds.position() - Point::ORIGIN; + let ranges = state.selection(); - let (rows, mut start, mut end) = state.selection_end_points(); - start += core::Vector::new(bounds.x, bounds.y); - end += core::Vector::new(bounds.x, bounds.y); - - let line_height = self - .format - .line_height - .to_absolute( - self.format.size.unwrap_or_else(|| renderer.default_size()), - ) - .0; - - let baseline_y = bounds.y - + (((start.y - bounds.y) * 10.0).ceil() / 10.0 / line_height) - .floor() - * line_height; - - for row in 0..rows { - let (x, width) = if row == 0 { - ( - start.x, - if rows == 1 { - end.x.min(bounds.x + bounds.width) - start.x - } else { - bounds.x + bounds.width - start.x - }, - ) - } else if row == rows - 1 { - (bounds.x, end.x - bounds.x) - } else { - (bounds.x, bounds.width) - }; - let y = baseline_y + row as f32 * line_height; - + for range in ranges + .into_iter() + .filter_map(|range| bounds.intersection(&(range + translation))) + { renderer.fill_quad( renderer::Quad { - bounds: core::Rectangle { - x, - y, - width, - height: line_height, - }, - snap: true, - ..Default::default() + bounds: range, + ..renderer::Quad::default() }, style.selection, ); @@ -715,7 +628,7 @@ where ) -> mouse::Interaction { let state = tree.state.downcast_ref::<State>(); - if state.is_hovered { + if state.is_hovered || state.dragging.is_some() { mouse::Interaction::Text } else { mouse::Interaction::default() @@ -854,3 +767,65 @@ pub fn default(theme: &Theme) -> Style { selection: theme.extended_palette().primary.weak.color, } } + +fn highlight_line( + line: &cosmic_text::BufferLine, + from: usize, + to: usize, +) -> impl Iterator<Item = (f32, f32)> + '_ { + let layout = line.layout_opt().map(Vec::as_slice).unwrap_or_default(); + + layout.iter().map(move |visual_line| { + let start = visual_line + .glyphs + .first() + .map(|glyph| glyph.start) + .unwrap_or(0); + let end = visual_line + .glyphs + .last() + .map(|glyph| glyph.end) + .unwrap_or(0); + + let range = start.max(from)..end.min(to); + + if range.is_empty() { + (0.0, 0.0) + } else if range.start == start && range.end == end { + (0.0, visual_line.w) + } else { + let first_glyph = visual_line + .glyphs + .iter() + .position(|glyph| range.start <= glyph.start) + .unwrap_or(0); + + let mut glyphs = visual_line.glyphs.iter(); + + let x: f32 = + glyphs.by_ref().take(first_glyph).map(|glyph| glyph.w).sum(); + + let width: f32 = glyphs + .take_while(|glyph| range.end > glyph.start) + .map(|glyph| glyph.w) + .sum(); + + (x, width) + } + }) +} + +fn visual_lines_offset(line: usize, buffer: &cosmic_text::Buffer) -> i32 { + let scroll = buffer.scroll(); + + let start = scroll.line.min(line); + let end = scroll.line.max(line); + + let visual_lines_offset: usize = buffer.lines[start..] + .iter() + .take(end - start) + .map(|line| line.layout_opt().map(Vec::len).unwrap_or_default()) + .sum(); + + visual_lines_offset as i32 * if scroll.line < line { 1 } else { -1 } +} diff --git a/src/text/rich.rs b/src/text/rich.rs index 7d162a7..29c43f5 100644 --- a/src/text/rich.rs +++ b/src/text/rich.rs @@ -1,4 +1,5 @@ use iced_widget::graphics::text::Paragraph; +use iced_widget::graphics::text::cosmic_text; use crate::core::alignment; use crate::core::clipboard; @@ -227,9 +228,26 @@ struct State<Link> { } impl<Link> State<Link> { - fn grapheme_line_and_index(&self, point: Point) -> Option<(usize, usize)> { - let cursor = self.paragraph.buffer().hit(point.x, point.y)?; - let value = self.paragraph.buffer().lines[cursor.line].text(); + fn grapheme_line_and_index( + &self, + point: Point, + bounds: core::Rectangle, + ) -> Option<(usize, usize)> { + let bounded_x = if point.y < bounds.y { + bounds.x + } else if point.y > bounds.y + bounds.height { + bounds.x + bounds.width + } else { + point.x.max(bounds.x).min(bounds.x + bounds.width) + }; + let bounded_y = point.y.max(bounds.y).min(bounds.y + bounds.height); + let bounded_point = Point::new(bounded_x, bounded_y); + let relative_point = + bounded_point - core::Vector::new(bounds.x, bounds.y); + + let buffer = self.paragraph.buffer(); + let cursor = buffer.hit(relative_point.x, relative_point.y)?; + let value = buffer.lines[cursor.line].text(); Some(( cursor.line, @@ -241,119 +259,48 @@ impl<Link> State<Link> { )) } - fn selection_end_points(&self) -> (usize, Point, Point) { + fn selection(&self) -> Vec<core::Rectangle> { let Selection { start, end, .. } = self.selection; - let (start_row, start_position) = self - .grapheme_position(start.line, start.index) - .unwrap_or_default(); + let buffer = self.paragraph.buffer(); + let line_height = self.paragraph.buffer().metrics().line_height; + let selected_lines = end.line - start.line + 1; - let (end_row, end_position) = self - .grapheme_position(end.line, end.index) - .unwrap_or_default(); - - ( - end_row.saturating_sub(start_row) + 1, - start_position, - end_position, - ) - } + let visual_lines_offset = visual_lines_offset(start.line, buffer); - fn grapheme_position( - &self, - line: usize, - index: usize, - ) -> Option<(usize, Point)> { - use unicode_segmentation::UnicodeSegmentation; - - let mut first_run_index = None; - let mut last_run_index = None; - let mut last_start = None; - let mut last_grapheme_count = 0; - let mut last_run_graphemes = 0; - let mut real_index = 0; - let mut graphemes_seen = 0; - - let mut glyphs = self - .paragraph - .buffer() - .layout_runs() + buffer + .lines + .iter() + .skip(start.line) + .take(selected_lines) .enumerate() - .filter(|(_, run)| run.line_i == line) - .flat_map(|(run_idx, run)| { - let line_top = run.line_top; - - if first_run_index.is_none() { - first_run_index = Some(run_idx); - } - - run.glyphs.iter().map(move |glyph| { - let mut glyph = glyph.clone(); - glyph.y += line_top; - (run_idx, glyph, run.text) - }) - }); - - let (_, glyph, _) = glyphs - .find(|(run_idx, glyph, text)| { - if Some(glyph.start) != last_start { - last_grapheme_count = - text[glyph.start..glyph.end].graphemes(false).count(); - last_start = Some(glyph.start); - graphemes_seen += last_grapheme_count; - last_run_graphemes += last_grapheme_count; - real_index += last_grapheme_count; - - if Some(*run_idx) != last_run_index - && graphemes_seen < index - { - real_index = last_grapheme_count; - last_run_graphemes = last_grapheme_count; - } - } else if Some(*run_idx) != last_run_index - && graphemes_seen < index - { - real_index = 0; - last_run_graphemes = 0; + .flat_map(|(i, line)| { + highlight_line( + line, + if i == 0 { start.index } else { 0 }, + if i == selected_lines - 1 { + end.index + } else { + line.text().len() + }, + ) + }) + .enumerate() + .filter_map(|(visual_line, (x, width))| { + if width > 0.0 { + Some(core::Rectangle { + x, + width, + y: (visual_line as i32 + visual_lines_offset) as f32 + * line_height + - buffer.scroll().vertical, + height: line_height, + }) + } else { + None } - - last_run_index = Some(*run_idx); - - graphemes_seen >= index }) - .or_else(|| glyphs.last())?; - - real_index -= graphemes_seen.saturating_sub(index); - real_index = - real_index.saturating_sub(last_run_index? - first_run_index?); - - last_run_graphemes = last_run_graphemes - .saturating_sub(last_run_index? - first_run_index?); - - let advance = if last_run_index? - first_run_index? <= 1 { - if real_index == 0 { - 0.0 - } else { - glyph.w - * (1.0 - - last_run_graphemes.saturating_sub(real_index) as f32 - / last_grapheme_count.max(1) as f32) - - glyph.w * (last_run_index? - first_run_index?) as f32 - } - } else { - -(glyph.w - * (1.0 - + last_run_graphemes.saturating_sub(real_index) as f32 - / last_grapheme_count.max(1) as f32)) - }; - - Some(( - last_run_index?, - Point::new( - glyph.x + glyph.x_offset * glyph.font_size + advance, - glyph.y - glyph.y_offset * glyph.font_size, - ), - )) + .collect() } } @@ -523,51 +470,17 @@ where if !state.selection.is_empty() { let bounds = layout.bounds(); + let translation = bounds.position() - Point::ORIGIN; + let ranges = state.selection(); - let (rows, mut start, mut end) = state.selection_end_points(); - start += core::Vector::new(bounds.x, bounds.y); - end += core::Vector::new(bounds.x, bounds.y); - - let line_height = self - .line_height - .to_absolute( - self.size.unwrap_or_else(|| renderer.default_size()), - ) - .0; - - let baseline_y = bounds.y - + (((start.y - bounds.y) * 10.0).ceil() / 10.0 / line_height) - .floor() - * line_height; - - for row in 0..rows { - let (x, width) = if row == 0 { - ( - start.x, - if rows == 1 { - end.x.min(bounds.x + bounds.width) - start.x - } else { - bounds.x + bounds.width - start.x - }, - ) - } else if row == rows - 1 { - (bounds.x, end.x - bounds.x) - } else { - (bounds.x, bounds.width) - }; - - let y = baseline_y + row as f32 * line_height; - + for range in ranges + .into_iter() + .filter_map(|range| bounds.intersection(&(range + translation))) + { renderer.fill_quad( renderer::Quad { - bounds: Rectangle { - x, - y, - width, - height: line_height, - }, - snap: true, - ..Default::default() + bounds: range, + ..renderer::Quad::default() }, style.selection, ); @@ -634,7 +547,7 @@ where shell.capture_event(); } - if let Some(position) = click_position { + if let Some(position) = cursor.position_over(bounds) { let click = mouse::Click::new( position, mouse::Button::Left, @@ -642,7 +555,7 @@ where ); let (line, index) = state - .grapheme_line_and_index(position) + .grapheme_line_and_index(position, bounds) .unwrap_or((0, 0)); match click.kind() { @@ -708,11 +621,11 @@ where } Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { - if let Some(position) = click_position + if let Some(position) = cursor.position() && let Some(dragging) = state.dragging { let (line, index) = state - .grapheme_line_and_index(position) + .grapheme_line_and_index(position, bounds) .unwrap_or((0, 0)); match dragging { @@ -898,7 +811,7 @@ where if state.hovered_link.is_some() { mouse::Interaction::Pointer - } else if cursor.is_over(layout.bounds()) { + } else if cursor.is_over(layout.bounds()) || state.dragging.is_some() { mouse::Interaction::Text } else { mouse::Interaction::None @@ -1007,3 +920,65 @@ where Element::new(text) } } + +fn highlight_line( + line: &cosmic_text::BufferLine, + from: usize, + to: usize, +) -> impl Iterator<Item = (f32, f32)> + '_ { + let layout = line.layout_opt().map(Vec::as_slice).unwrap_or_default(); + + layout.iter().map(move |visual_line| { + let start = visual_line + .glyphs + .first() + .map(|glyph| glyph.start) + .unwrap_or(0); + let end = visual_line + .glyphs + .last() + .map(|glyph| glyph.end) + .unwrap_or(0); + + let range = start.max(from)..end.min(to); + + if range.is_empty() { + (0.0, 0.0) + } else if range.start == start && range.end == end { + (0.0, visual_line.w) + } else { + let first_glyph = visual_line + .glyphs + .iter() + .position(|glyph| range.start <= glyph.start) + .unwrap_or(0); + + let mut glyphs = visual_line.glyphs.iter(); + + let x: f32 = + glyphs.by_ref().take(first_glyph).map(|glyph| glyph.w).sum(); + + let width: f32 = glyphs + .take_while(|glyph| range.end > glyph.start) + .map(|glyph| glyph.w) + .sum(); + + (x, width) + } + }) +} + +fn visual_lines_offset(line: usize, buffer: &cosmic_text::Buffer) -> i32 { + let scroll = buffer.scroll(); + + let start = scroll.line.min(line); + let end = scroll.line.max(line); + + let visual_lines_offset: usize = buffer.lines[start..] + .iter() + .take(end - start) + .map(|line| line.layout_opt().map(Vec::len).unwrap_or_default()) + .sum(); + + visual_lines_offset as i32 * if scroll.line < line { 1 } else { -1 } +} |
