pub(crate) mod markdown;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use iced::advanced::graphics::text::Paragraph;
use iced::advanced::graphics::text::cosmic_text::{Affinity, BufferLine, Cursor, LayoutRun};
use iced::advanced::layout::Layout;
use iced::advanced::text::{Paragraph as _, Span};
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::{Clipboard, Shell, Widget, clipboard, layout, mouse, renderer};
use iced::keyboard;
use iced::keyboard::key;
use iced::widget::text::{Alignment, LineHeight, Shaping, Wrapping};
use iced::{
Color, Element, Event, Font, Length, Pixels, Point, Rectangle, Size, Vector, alignment,
};
use super::theme;
use super::widgets::{CLICK_DRAG_MAX_DISTANCE, DOUBLE_CLICK_WINDOW};
use crate::util::is_word_char;
#[derive(Debug, Clone, Copy)]
pub(crate) struct Style {
pub color: Option<Color>,
pub selection: Color,
}
impl Default for Style {
fn default() -> Self {
Self {
color: None,
selection: theme::ACCENT_DIM,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum Direction {
#[default]
Right,
Left,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct SelectionEnd {
line: usize,
index: usize,
}
impl SelectionEnd {
const fn new(line: usize, index: usize) -> Self {
Self { line, index }
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Selection {
start: SelectionEnd,
end: SelectionEnd,
direction: Direction,
}
impl Selection {
fn is_empty(&self) -> bool {
self.start == self.end
}
fn active_end(&self) -> SelectionEnd {
match self.direction {
Direction::Right => self.end,
Direction::Left => self.start,
}
}
fn anchor(&self) -> SelectionEnd {
match self.direction {
Direction::Right => self.start,
Direction::Left => self.end,
}
}
fn select_range(&mut self, start: SelectionEnd, end: SelectionEnd) {
self.start = start.min(end);
self.end = start.max(end);
}
fn change_selection(&mut self, new_end: SelectionEnd) {
let (start, end) = match self.direction {
Direction::Right => {
if new_end < self.start {
self.direction = Direction::Left;
}
(self.start, new_end)
}
Direction::Left => {
if new_end > self.end {
self.direction = Direction::Right;
}
(new_end, self.end)
}
};
self.select_range(start, end);
}
fn select_all(&mut self, paragraph: &Paragraph) {
let lines = ¶graph.buffer().lines;
let Some(last) = lines.last() else {
*self = Self::default();
return;
};
self.direction = Direction::Right;
self.select_range(
SelectionEnd::new(0, 0),
SelectionEnd::new(lines.len() - 1, last.text().len()),
);
}
fn select_line(&mut self, line: usize, paragraph: &Paragraph) {
let Some(source) = paragraph.buffer().lines.get(line) else {
return;
};
self.direction = Direction::Right;
self.select_range(
SelectionEnd::new(line, 0),
SelectionEnd::new(line, source.text().len()),
);
}
fn select_word(&mut self, line: usize, index: usize, paragraph: &Paragraph) {
let Some(source) = paragraph.buffer().lines.get(line) else {
return;
};
let (start, end) = word_bounds(source.text(), index);
self.direction = Direction::Right;
self.select_range(SelectionEnd::new(line, start), SelectionEnd::new(line, end));
}
fn text(&self, paragraph: &Paragraph) -> String {
let lines = ¶graph.buffer().lines;
let mut value = String::new();
for line_index in self.start.line..=self.end.line {
let Some(line) = lines.get(line_index) else {
break;
};
let text = line.text();
let from = if line_index == self.start.line {
let index = clamp_char_boundary(text, self.start.index);
split_character(paragraph, line_index, index).map_or(index, |(start, _)| start)
} else {
0
};
let to = if line_index == self.end.line {
let index = clamp_char_boundary(text, self.end.index);
split_character(paragraph, line_index, index).map_or(index, |(_, end)| end)
} else {
text.len()
};
if from < to {
value.push_str(&text[from..to]);
}
if line_index != self.end.line {
value.push_str(line.ending().as_str());
}
}
value
}
}
fn clamp_char_boundary(text: &str, index: usize) -> usize {
text.floor_char_boundary(index.min(text.len()))
}
fn character_span(run: &LayoutRun<'_>, index: usize) -> Option<(usize, usize)> {
let index = clamp_char_boundary(run.text, index);
run.glyphs
.iter()
.find(|glyph| glyph.start < index && index < glyph.end)
.map(|glyph| (glyph.start, glyph.end))
}
fn split_character(paragraph: &Paragraph, line: usize, index: usize) -> Option<(usize, usize)> {
paragraph
.buffer()
.layout_runs()
.filter(|run| run.line_i == line)
.find_map(|run| character_span(&run, index))
}
fn char_class(c: char) -> u8 {
if is_word_char(c) {
0
} else if c.is_whitespace() {
1
} else {
2
}
}
fn word_bounds(text: &str, index: usize) -> (usize, usize) {
let index = clamp_char_boundary(text, index);
let seed = text[index..]
.chars()
.next()
.filter(|c| is_word_char(*c))
.or_else(|| text[..index].chars().next_back());
let Some(seed) = seed else {
return (0, 0);
};
let seed = char_class(seed);
let mut start = index;
while let Some(c) = text[..start].chars().next_back() {
if char_class(c) != seed {
break;
}
start -= c.len_utf8();
}
let mut end = index;
while let Some(c) = text[end..].chars().next() {
if char_class(c) != seed {
break;
}
end += c.len_utf8();
}
(start, end)
}
static ACTIVE: AtomicU64 = AtomicU64::new(0);
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn deactivate() {
ACTIVE.store(0, Ordering::Relaxed);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClickKind {
Single,
Double,
Triple,
}
impl ClickKind {
const fn next(self) -> Self {
match self {
Self::Single => Self::Double,
Self::Double | Self::Triple => Self::Triple,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Click {
kind: ClickKind,
position: Point,
time: Instant,
}
impl Click {
fn new(previous: Option<Self>, position: Point, time: Instant) -> Self {
let kind = match previous {
Some(previous) if previous.is_consecutive(position, time) => previous.kind.next(),
_ => ClickKind::Single,
};
Self {
kind,
position,
time,
}
}
fn is_consecutive(&self, position: Point, time: Instant) -> bool {
self.position.distance(position) <= CLICK_DRAG_MAX_DISTANCE
&& time.duration_since(self.time) <= DOUBLE_CLICK_WINDOW
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Dragging {
Character,
Word,
Line,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shortcut {
Copy,
SelectAll,
}
fn shortcut(
key: &keyboard::Key,
physical_key: key::Physical,
modifiers: keyboard::Modifiers,
) -> Option<Shortcut> {
if !modifiers.command() {
return None;
}
match key.to_latin(physical_key)? {
'c' => Some(Shortcut::Copy),
'a' => Some(Shortcut::SelectAll),
_ => None,
}
}
pub(crate) struct Selectable<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Link: Clone + 'static,
Theme: 'a,
Renderer: iced::advanced::text::Renderer<Font = Font>,
{
spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
size: Option<Pixels>,
width: Length,
font: Option<Font>,
wrapping: Wrapping,
style: Box<dyn Fn(&Theme) -> Style + 'a>,
on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
}
pub(crate) fn rich_text<'a, Link, Message, Theme, Renderer>(
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> Selectable<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: 'a,
Renderer: iced::advanced::text::Renderer<Font = Font>,
{
Selectable::<Link, Message, Theme, Renderer> {
spans: Box::new(spans),
size: None,
width: Length::Shrink,
font: None,
wrapping: Wrapping::default(),
style: Box::new(|_theme| Style::default()),
on_link_click: None,
}
}
impl<'a, Link, Message, Theme, Renderer> Selectable<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: 'a,
Renderer: iced::advanced::text::Renderer<Font = Font>,
{
pub(crate) fn size(mut self, size: impl Into<Pixels>) -> Self {
self.size = Some(size.into());
self
}
pub(crate) fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
pub(crate) fn font(mut self, font: impl Into<Font>) -> Self {
self.font = Some(font.into());
self
}
pub(crate) fn wrapping(mut self, wrapping: Wrapping) -> Self {
self.wrapping = wrapping;
self
}
pub(crate) fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self {
self.style = Box::new(style);
self
}
pub(crate) fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self {
self.on_link_click = Some(Box::new(on_link_click));
self
}
}
impl<'a, Link, Message, Theme, Renderer> From<Selectable<'a, Link, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Link: Clone + 'static,
Theme: 'a,
Renderer: iced::advanced::text::Renderer<Font = Font, Paragraph = Paragraph> + 'a,
{
fn from(widget: Selectable<'a, Link, Message, Theme, Renderer>) -> Self {
Self::new(widget)
}
}
struct State<Link> {
id: u64,
spans: Vec<Span<'static, Link, Font>>,
paragraph: Paragraph,
span_pressed: Option<usize>,
hovered_link: Option<usize>,
selection: Selection,
dragging: Option<Dragging>,
goal: Option<usize>,
last_click: Option<Click>,
modifiers: keyboard::Modifiers,
}
fn pointer(position: Point, bounds: Rectangle) -> Point {
Point::new(
position.x.clamp(bounds.x, bounds.x + bounds.width) - bounds.x,
position.y.clamp(bounds.y, bounds.y + bounds.height) - bounds.y,
)
}
impl<Link> State<Link> {
fn hit(&self, position: Point, bounds: Rectangle) -> Option<SelectionEnd> {
let point = pointer(position, bounds);
let cursor = self.paragraph.buffer().hit(point.x, point.y)?;
Some(SelectionEnd::new(cursor.line, cursor.index))
}
fn selection_quads(&self) -> Vec<Rectangle> {
let Selection { start, end, .. } = self.selection;
self.paragraph
.buffer()
.layout_runs()
.filter(|run| (start.line..=end.line).contains(&run.line_i))
.filter_map(|run| {
let from = if run.line_i == start.line {
let index = clamp_char_boundary(run.text, start.index);
character_span(&run, index).map_or(index, |(start, _)| start)
} else {
0
};
let to = if run.line_i == end.line {
let index = clamp_char_boundary(run.text, end.index);
character_span(&run, index).map_or(index, |(_, end)| end)
} else {
run.text.len()
};
if from >= to {
return None;
}
let (x, width) = run.highlight(
Cursor::new_with_affinity(run.line_i, from, Affinity::After),
Cursor::new_with_affinity(run.line_i, to, Affinity::Before),
)?;
(width > 0.0).then(|| {
Rectangle::new(
Point::new(x, run.line_top),
Size::new(width, run.line_height),
)
})
})
.collect()
}
}
fn char_left(end: SelectionEnd, lines: &[BufferLine]) -> SelectionEnd {
if end.index > 0 {
let text = lines.get(end.line).map_or("", BufferLine::text);
SelectionEnd::new(end.line, clamp_char_boundary(text, end.index - 1))
} else if end.line > 0 {
SelectionEnd::new(end.line - 1, line_len(lines, end.line - 1))
} else {
end
}
}
fn char_right(end: SelectionEnd, lines: &[BufferLine]) -> SelectionEnd {
let text = lines.get(end.line).map_or("", BufferLine::text);
if let Some(next) = text.get(end.index..).and_then(|rest| rest.chars().next()) {
SelectionEnd::new(end.line, end.index + next.len_utf8())
} else if end.line + 1 < lines.len() {
SelectionEnd::new(end.line + 1, 0)
} else {
end
}
}
fn word_left(end: SelectionEnd, lines: &[BufferLine]) -> SelectionEnd {
let line = if end.index == 0 && end.line > 0 {
end.line - 1
} else {
end.line
};
let text = lines.get(line).map_or("", BufferLine::text);
let mut index = if line == end.line {
end.index.min(text.len())
} else {
text.len()
};
while let Some((start, c)) = text[..index].char_indices().next_back() {
if is_word_char(c) {
break;
}
index = start;
}
while let Some((start, c)) = text[..index].char_indices().next_back() {
if !is_word_char(c) {
break;
}
index = start;
}
SelectionEnd::new(line, index)
}
fn word_right(end: SelectionEnd, lines: &[BufferLine]) -> SelectionEnd {
let line = if end.index >= line_len(lines, end.line) && end.line + 1 < lines.len() {
end.line + 1
} else {
end.line
};
let text = lines.get(line).map_or("", BufferLine::text);
let mut index = if line == end.line {
end.index.min(text.len())
} else {
0
};
while let Some(c) = text[index..].chars().next() {
if is_word_char(c) {
break;
}
index += c.len_utf8();
}
while let Some(c) = text[index..].chars().next() {
if !is_word_char(c) {
break;
}
index += c.len_utf8();
}
SelectionEnd::new(line, index)
}
fn line_len(lines: &[BufferLine], line: usize) -> usize {
lines.get(line).map_or(0, |line| line.text().len())
}
fn word_edge(end: SelectionEnd, anchor: SelectionEnd, paragraph: &Paragraph) -> SelectionEnd {
let text = paragraph
.buffer()
.lines
.get(end.line)
.map_or("", |line| line.text());
let (start, stop) = word_bounds(text, end.index);
SelectionEnd::new(end.line, if end > anchor { stop } else { start })
}
fn extended_to(
key: &keyboard::Key,
modifiers: keyboard::Modifiers,
selection: Selection,
paragraph: &Paragraph,
goal: &mut Option<usize>,
) -> Option<SelectionEnd> {
let keyboard::Key::Named(name) = key.as_ref() else {
return None;
};
let end = selection.active_end();
let lines = ¶graph.buffer().lines;
let vertical = matches!(name, key::Named::ArrowUp | key::Named::ArrowDown)
&& !modifiers.macos_command()
&& !modifiers.jump();
if vertical {
let line = if name == key::Named::ArrowUp {
end.line.saturating_sub(1)
} else {
(end.line + 1).min(lines.len().saturating_sub(1))
};
let column = *goal.get_or_insert(end.index);
let text = lines.get(line).map_or("", BufferLine::text);
return Some(SelectionEnd::new(line, clamp_char_boundary(text, column)));
}
*goal = None;
let line_start = SelectionEnd::new(end.line, 0);
let line_end = SelectionEnd::new(end.line, line_len(lines, end.line));
let document_end = SelectionEnd::new(
lines.len().saturating_sub(1),
line_len(lines, lines.len().saturating_sub(1)),
);
Some(match (name, modifiers.macos_command(), modifiers.jump()) {
(key::Named::ArrowUp, true, _) | (key::Named::Home, _, true) => SelectionEnd::default(),
(key::Named::ArrowDown, true, _) | (key::Named::End, _, true) => document_end,
(key::Named::ArrowLeft, true, _)
| (key::Named::Home, _, false)
| (key::Named::ArrowUp, false, true) => line_start,
(key::Named::ArrowRight, true, _)
| (key::Named::End, _, false)
| (key::Named::ArrowDown, false, true) => line_end,
(key::Named::ArrowLeft, false, true) => word_left(end, lines),
(key::Named::ArrowRight, false, true) => word_right(end, lines),
(key::Named::ArrowLeft, false, false) => char_left(end, lines),
(key::Named::ArrowRight, false, false) => char_right(end, lines),
_ => return None,
})
}
impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Selectable<'_, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Renderer: iced::advanced::text::Renderer<Font = Font, Paragraph = Paragraph>,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State<Link>>()
}
fn state(&self) -> tree::State {
tree::State::new(State::<Link> {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
spans: Vec::new(),
paragraph: Paragraph::default(),
span_pressed: None,
hovered_link: None,
selection: Selection::default(),
dragging: None,
goal: None,
last_click: None,
modifiers: keyboard::Modifiers::default(),
})
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: Length::Shrink,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let state = tree.state.downcast_mut::<State<Link>>();
let spans = self.spans.as_ref().as_ref();
layout::sized(limits, self.width, Length::Shrink, |limits| {
let bounds = limits.max();
let size = self.size.unwrap_or_else(|| renderer.default_size());
let font = self.font.unwrap_or_else(|| renderer.default_font());
let with_spans = || iced::advanced::Text {
content: spans,
bounds,
size,
line_height: LineHeight::default(),
font,
align_x: Alignment::Default,
align_y: alignment::Vertical::Top,
shaping: Shaping::Advanced,
wrapping: self.wrapping,
};
if state.spans != spans {
state.paragraph = Paragraph::with_spans(with_spans());
state.spans = spans.iter().cloned().map(Span::to_static).collect();
state.selection = Selection::default();
state.dragging = None;
state.goal = None;
} else {
match state.paragraph.compare(with_spans().with_content(())) {
iced::advanced::text::Difference::None => {}
iced::advanced::text::Difference::Bounds => state.paragraph.resize(bounds),
iced::advanced::text::Difference::Shape => {
state.paragraph = Paragraph::with_spans(with_spans());
}
}
}
state.paragraph.min_bounds()
})
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
defaults: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let bounds = layout.bounds();
if !bounds.intersects(viewport) {
return;
}
let state = tree.state.downcast_ref::<State<Link>>();
let style = (self.style)(theme);
let translation = Vector::new(bounds.x, bounds.y);
for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() {
let hovered_link = self.on_link_click.is_some() && state.hovered_link == Some(index);
if span.highlight.is_none() && !span.underline && !span.strikethrough && !hovered_link {
continue;
}
let regions = state.paragraph.span_bounds(index);
if let Some(highlight) = span.highlight {
for region in ®ions {
let region = Rectangle::new(
region.position() - Vector::new(span.padding.left, span.padding.top),
region.size() + Size::new(span.padding.x(), span.padding.y()),
);
renderer.fill_quad(
renderer::Quad {
bounds: region + translation,
border: highlight.border,
..renderer::Quad::default()
},
highlight.background,
);
}
}
if span.underline || span.strikethrough || hovered_link {
let size = span
.size
.or(self.size)
.unwrap_or_else(|| renderer.default_size());
let line_height = span.line_height.unwrap_or_default().to_absolute(size);
let color = span.color.or(style.color).unwrap_or(defaults.text_color);
let baseline =
translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0);
if span.underline || hovered_link {
let underline_at = baseline - Vector::new(0.0, size.0 * 0.08);
for region in ®ions {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
region.position() + underline_at,
Size::new(region.width, 1.0),
),
..renderer::Quad::default()
},
color,
);
}
}
if span.strikethrough {
let strike_at = baseline - Vector::new(0.0, size.0 / 2.0);
for region in ®ions {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
region.position() + strike_at,
Size::new(region.width, 1.0),
),
..renderer::Quad::default()
},
color,
);
}
}
}
}
if !state.selection.is_empty() {
for quad in state.selection_quads() {
if let Some(quad) = bounds.intersection(&(quad + translation)) {
renderer.fill_quad(
renderer::Quad {
bounds: quad,
..renderer::Quad::default()
},
style.selection,
);
}
}
}
renderer.fill_paragraph(
&state.paragraph,
bounds.position(),
style.color.unwrap_or(defaults.text_color),
*viewport,
);
}
#[expect(
clippy::too_many_lines,
reason = "one event dispatch table over the widget's interlocking input state"
)]
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State<Link>>();
let bounds = layout.bounds();
if viewport.intersection(&bounds).is_none()
&& state.selection.is_empty()
&& state.dragging.is_none()
{
return;
}
let selection_before = state.selection;
let hovered_before = state.hovered_link;
state.hovered_link = cursor
.position_over(bounds)
.map(|position| pointer(position, bounds))
.and_then(|point| state.paragraph.hit_span(point))
.filter(|span| {
self.spans
.as_ref()
.as_ref()
.get(*span)
.is_some_and(|span| span.link.is_some())
});
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(iced::touch::Event::FingerPressed { .. }) => {
if state.hovered_link.is_some() {
state.span_pressed = state.hovered_link;
shell.capture_event();
}
if let Some(position) = cursor.position_over(bounds) {
ACTIVE.store(state.id, Ordering::Relaxed);
state.goal = None;
let click = Click::new(state.last_click, position, Instant::now());
state.last_click = Some(click);
if let Some(end) = state.hit(position, bounds) {
match click.kind {
ClickKind::Single => {
if state.modifiers.shift() {
state.selection.change_selection(end);
} else {
state.selection.select_range(end, end);
}
state.dragging = Some(Dragging::Character);
}
ClickKind::Double => {
state
.selection
.select_word(end.line, end.index, &state.paragraph);
state.dragging = Some(Dragging::Word);
}
ClickKind::Triple => {
state.selection.select_line(end.line, &state.paragraph);
state.dragging = Some(Dragging::Line);
}
}
}
shell.capture_event();
} else {
state.selection = Selection::default();
state.dragging = None;
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| Event::Touch(
iced::touch::Event::FingerLifted { .. } | iced::touch::Event::FingerLost { .. },
) => {
state.dragging = None;
let clicked_link = state
.span_pressed
.take()
.filter(|span| Some(*span) == state.hovered_link)
.filter(|_| state.selection.is_empty())
.filter(|_| {
!matches!(event, Event::Touch(iced::touch::Event::FingerLost { .. }))
});
if let Some((link, on_link_click)) = clicked_link.and_then(|span| {
let span = self.spans.as_ref().as_ref().get(span)?;
Some((span.link.clone()?, self.on_link_click.as_deref()?))
}) {
shell.publish(on_link_click(link));
}
}
Event::Mouse(mouse::Event::CursorMoved { .. })
| Event::Touch(iced::touch::Event::FingerMoved { .. }) => {
if let Some(dragging) = state.dragging
&& let Some(position) = cursor.land().position()
&& let Some(end) = state.hit(position, bounds)
{
let anchor = state.selection.anchor();
let target = match dragging {
Dragging::Character => end,
Dragging::Word => word_edge(end, anchor, &state.paragraph),
Dragging::Line => SelectionEnd::new(
end.line,
if end > anchor {
line_len(&state.paragraph.buffer().lines, end.line)
} else {
0
},
),
};
state.selection.change_selection(target);
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key,
physical_key,
modifiers,
..
}) if ACTIVE.load(Ordering::Relaxed) == state.id => {
match shortcut(key, *physical_key, *modifiers) {
Some(Shortcut::Copy) if !state.selection.is_empty() => {
let text = state.selection.text(&state.paragraph);
clipboard.write(clipboard::Kind::Standard, text);
shell.capture_event();
}
Some(Shortcut::SelectAll) => {
state.selection.select_all(&state.paragraph);
state.goal = None;
shell.capture_event();
}
_ if matches!(key.as_ref(), keyboard::Key::Named(key::Named::Escape)) => {
if !state.selection.is_empty() || state.dragging.is_some() {
state.dragging = None;
state.selection = Selection::default();
state.goal = None;
shell.capture_event();
}
}
_ => {
if modifiers.shift()
&& let Some(end) = extended_to(
key,
*modifiers,
state.selection,
&state.paragraph,
&mut state.goal,
)
{
state.selection.change_selection(end);
shell.capture_event();
}
}
}
}
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
state.modifiers = *modifiers;
}
_ => {}
}
if state.selection != selection_before || state.hovered_link != hovered_before {
shell.request_redraw();
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let state = tree.state.downcast_ref::<State<Link>>();
if state.hovered_link.is_some() {
mouse::Interaction::Pointer
} else if cursor.is_over(layout.bounds()) || state.dragging.is_some() {
mouse::Interaction::Text
} else {
mouse::Interaction::None
}
}
}
#[cfg(test)]
mod tests {
use super::{Shortcut, shortcut};
use iced::keyboard::key::{Code, Physical};
use iced::keyboard::{Key, Modifiers};
#[test]
fn command_c_with_a_cyrillic_key_character_still_copies() {
assert_eq!(
shortcut(
&Key::Character("с".into()),
Physical::Code(Code::KeyC),
Modifiers::COMMAND
),
Some(Shortcut::Copy)
);
}
#[test]
fn command_a_with_a_cyrillic_key_character_still_selects_everything() {
assert_eq!(
shortcut(
&Key::Character("а".into()),
Physical::Code(Code::KeyA),
Modifiers::COMMAND
),
Some(Shortcut::SelectAll)
);
}
}