use std::collections::BTreeMap;
use std::fmt;
use std::ops::Range;
use crate::core::rect::Rect;
use crate::sanitize::char_display_width;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WidgetId(pub String);
impl WidgetId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl AsRef<str> for WidgetId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for WidgetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for WidgetId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for WidgetId {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SelectionId(pub String);
impl SelectionId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl AsRef<str> for SelectionId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<&str> for SelectionId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for SelectionId {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SelectionGroup(pub String);
impl SelectionGroup {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl AsRef<str> for SelectionGroup {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<&str> for SelectionGroup {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for SelectionGroup {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Position {
pub x: u16,
pub y: u16,
}
impl Position {
pub const fn new(x: u16, y: u16) -> Self {
Self { x, y }
}
pub fn local_to(self, area: Rect) -> Self {
Self {
x: self.x.saturating_sub(area.x),
y: self.y.saturating_sub(area.y),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseButton {
Left,
Right,
Middle,
WheelUp,
WheelDown,
Other(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseCursor {
Default,
Pointer,
Text,
Grab,
Grabbing,
ResizeHorizontal,
ResizeVertical,
Crosshair,
NotAllowed,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WidgetAction {
Activate,
Focus,
Toggle,
Open,
Copy,
CopyCode,
Select,
Scroll,
Drag,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WidgetRole {
Unknown,
Button,
ListItem,
Tab,
TextSpan,
CodeBlock,
CodeLine,
TodoItem,
StatusIndicator,
ScrollbarThumb,
Link,
Panel,
Pane,
Region,
Scrollbar,
Status,
Text,
Toggle,
Tooltip,
Transcript,
TranscriptRow,
BoardColumn,
BoardCard,
Effect,
CommandPaletteEntry,
ModelRow,
RuntimeDiagnostic,
Custom(String),
}
impl Default for WidgetRole {
fn default() -> Self {
Self::Unknown
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct WidgetState {
pub focused: bool,
pub hovered: bool,
pub selected: bool,
pub disabled: bool,
pub checked: bool,
pub active: bool,
pub expanded: bool,
}
impl WidgetState {
pub const fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub const fn hovered(mut self, hovered: bool) -> Self {
self.hovered = hovered;
self
}
pub const fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub const fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub const fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WidgetValue {
Text(String),
Count(usize),
Percent(u16),
Status(String),
Language(String),
LineNumber(usize),
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HitRegion {
pub id: WidgetId,
pub area: Rect,
pub role: WidgetRole,
pub label: String,
pub tooltip: Option<String>,
pub action: Option<WidgetAction>,
pub cursor: Option<MouseCursor>,
pub z_index: i16,
pub row: Option<usize>,
pub column: Option<usize>,
pub selection_group: Option<SelectionGroup>,
pub description: Option<String>,
pub shortcut: Option<String>,
pub state: WidgetState,
pub value: Option<WidgetValue>,
}
impl HitRegion {
pub fn new(id: impl Into<WidgetId>, area: Rect) -> Self {
Self {
id: id.into(),
area,
role: WidgetRole::Unknown,
label: String::new(),
tooltip: None,
action: None,
cursor: None,
z_index: 0,
row: None,
column: None,
selection_group: None,
description: None,
shortcut: None,
state: WidgetState::default(),
value: None,
}
}
pub fn with_role(mut self, role: WidgetRole) -> Self {
self.role = role;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn with_action(mut self, action: WidgetAction) -> Self {
self.action = Some(action);
self
}
pub fn with_cursor(mut self, cursor: MouseCursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_z_index(mut self, z_index: i16) -> Self {
self.z_index = z_index;
self
}
pub fn with_row(mut self, row: usize) -> Self {
self.row = Some(row);
self
}
pub fn with_column(mut self, column: usize) -> Self {
self.column = Some(column);
self
}
pub fn with_selection_group(mut self, group: impl Into<SelectionGroup>) -> Self {
self.selection_group = Some(group.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn with_state(mut self, state: WidgetState) -> Self {
self.state = state;
self
}
pub fn with_value(mut self, value: WidgetValue) -> Self {
self.value = Some(value);
self
}
pub fn contains(&self, x: u16, y: u16) -> bool {
rect_contains(self.area, x, y)
}
pub fn local_position(&self, x: u16, y: u16) -> Position {
Position::new(x, y).local_to(self.area)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WidgetMetadata {
pub id: WidgetId,
pub role: WidgetRole,
pub label: String,
pub tooltip: Option<String>,
pub action: Option<WidgetAction>,
pub cursor: Option<MouseCursor>,
pub z_index: i16,
pub row: Option<usize>,
pub column: Option<usize>,
pub selection_group: Option<SelectionGroup>,
pub description: Option<String>,
pub shortcut: Option<String>,
pub state: WidgetState,
pub value: Option<WidgetValue>,
}
impl WidgetMetadata {
pub fn new(id: impl Into<WidgetId>) -> Self {
Self {
id: id.into(),
..Self::default()
}
}
pub fn with_role(mut self, role: WidgetRole) -> Self {
self.role = role;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn with_action(mut self, action: WidgetAction) -> Self {
self.action = Some(action);
self
}
pub fn with_cursor(mut self, cursor: MouseCursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_z_index(mut self, z_index: i16) -> Self {
self.z_index = z_index;
self
}
pub fn with_row(mut self, row: usize) -> Self {
self.row = Some(row);
self
}
pub fn with_column(mut self, column: usize) -> Self {
self.column = Some(column);
self
}
pub fn with_selection_group(mut self, group: impl Into<SelectionGroup>) -> Self {
self.selection_group = Some(group.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn with_state(mut self, state: WidgetState) -> Self {
self.state = state;
self
}
pub fn with_value(mut self, value: WidgetValue) -> Self {
self.value = Some(value);
self
}
pub fn into_region(self, area: Rect) -> HitRegion {
HitRegion {
id: self.id,
area,
role: self.role,
label: self.label,
tooltip: self.tooltip,
action: self.action,
cursor: self.cursor,
z_index: self.z_index,
row: self.row,
column: self.column,
selection_group: self.selection_group,
description: self.description,
shortcut: self.shortcut,
state: self.state,
value: self.value,
}
}
}
impl Default for WidgetId {
fn default() -> Self {
Self(String::new())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TextRange {
pub line: usize,
pub start_col: usize,
pub end_col: usize,
}
impl TextRange {
pub const fn new(line: usize, start_col: usize, end_col: usize) -> Self {
Self {
line,
start_col,
end_col,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CopyTransform {
PlainText,
MarkdownSource,
CodeOnly,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectableSpan {
pub id: SelectionId,
pub text: String,
pub source_range: Range<usize>,
pub screen_area: Rect,
pub group: Option<SelectionGroup>,
pub source_id: WidgetId,
pub logical_range: TextRange,
pub copyable: bool,
pub copy_transform: Option<CopyTransform>,
}
impl SelectableSpan {
pub fn new(
id: impl Into<SelectionId>,
text: impl Into<String>,
source_range: Range<usize>,
screen_area: Rect,
) -> Self {
Self {
id: id.into(),
text: text.into(),
source_range,
screen_area,
group: None,
source_id: WidgetId::default(),
logical_range: TextRange::new(0, 0, 0),
copyable: true,
copy_transform: None,
}
}
pub fn from_logical(
id: impl Into<SelectionId>,
source_id: impl Into<WidgetId>,
screen_area: Rect,
logical_range: TextRange,
text: impl Into<String>,
) -> Self {
let text = text.into();
let end = text.len();
Self {
id: id.into(),
text,
source_range: 0..end,
screen_area,
group: None,
source_id: source_id.into(),
logical_range,
copyable: true,
copy_transform: None,
}
}
pub fn with_group(mut self, group: impl Into<SelectionGroup>) -> Self {
self.group = Some(group.into());
self
}
pub fn with_source_id(mut self, source_id: impl Into<WidgetId>) -> Self {
self.source_id = source_id.into();
self
}
pub fn with_logical_range(mut self, range: TextRange) -> Self {
self.logical_range = range;
self
}
pub fn with_copyable(mut self, copyable: bool) -> Self {
self.copyable = copyable;
self
}
pub fn with_copy_transform(mut self, transform: CopyTransform) -> Self {
self.copy_transform = Some(transform);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScrollRowHit {
pub row_id: WidgetId,
pub logical_row: usize,
pub source_line: Option<usize>,
pub span_id: Option<SelectionId>,
pub item_id: Option<WidgetId>,
pub wrapped_continuation: bool,
}
impl ScrollRowHit {
pub fn new(row_id: impl Into<WidgetId>, logical_row: usize) -> Self {
Self {
row_id: row_id.into(),
logical_row,
source_line: None,
span_id: None,
item_id: None,
wrapped_continuation: false,
}
}
pub fn with_source_line(mut self, source_line: usize) -> Self {
self.source_line = Some(source_line);
self
}
pub fn with_span_id(mut self, span_id: impl Into<SelectionId>) -> Self {
self.span_id = Some(span_id.into());
self
}
pub fn with_item_id(mut self, item_id: impl Into<WidgetId>) -> Self {
self.item_id = Some(item_id.into());
self
}
pub fn with_wrapped_continuation(mut self, continuation: bool) -> Self {
self.wrapped_continuation = continuation;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScrollHitRegion {
pub id: WidgetId,
pub viewport: Rect,
pub scroll_offset: usize,
pub rows: Vec<ScrollRowHit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicalHit {
pub region_id: WidgetId,
pub row_id: WidgetId,
pub logical_row: usize,
pub source_line: Option<usize>,
pub span_id: Option<SelectionId>,
pub item_id: Option<WidgetId>,
pub wrapped_continuation: bool,
pub screen_row: usize,
pub local: Position,
pub global: Position,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InteractionLayer {
pub regions: Vec<HitRegion>,
pub selectable_spans: Vec<SelectableSpan>,
pub scroll_regions: Vec<ScrollHitRegion>,
}
impl InteractionLayer {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.regions.clear();
self.selectable_spans.clear();
self.scroll_regions.clear();
}
pub fn push_region(&mut self, region: HitRegion) -> &HitRegion {
self.regions.push(region);
self.regions.last().expect("region was just pushed")
}
pub fn push_metadata_region(&mut self, area: Rect, metadata: WidgetMetadata) -> &HitRegion {
self.push_region(metadata.into_region(area))
}
pub fn push_cell(&mut self, x: u16, y: u16, metadata: WidgetMetadata) -> &HitRegion {
self.push_metadata_region(Rect::new(x, y, 1, 1), metadata)
}
pub fn push_selectable_span(&mut self, span: SelectableSpan) -> &SelectableSpan {
self.selectable_spans.push(span);
self.selectable_spans
.last()
.expect("selectable span was just pushed")
}
pub fn push_scroll_region(
&mut self,
id: impl Into<WidgetId>,
viewport: Rect,
scroll_offset: usize,
rows: Vec<ScrollRowHit>,
) -> &ScrollHitRegion {
self.scroll_regions.push(ScrollHitRegion {
id: id.into(),
viewport,
scroll_offset,
rows,
});
self.scroll_regions
.last()
.expect("scroll region was just pushed")
}
pub fn hit_test(&self, x: u16, y: u16) -> Option<&HitRegion> {
self.regions
.iter()
.enumerate()
.filter(|(_, region)| region.contains(x, y))
.max_by_key(|(index, region)| (region.z_index, *index))
.map(|(_, region)| region)
}
pub fn hit_test_position(&self, position: Position) -> Option<&HitRegion> {
self.hit_test(position.x, position.y)
}
pub fn region_by_id(&self, id: &WidgetId) -> Option<&HitRegion> {
self.regions.iter().rev().find(|region| ®ion.id == id)
}
pub fn regions_for_role(&self, role: WidgetRole) -> impl Iterator<Item = &HitRegion> {
self.regions
.iter()
.filter(move |region| region.role == role)
}
pub fn dirty_regions_for_ids<'a>(
&'a self,
ids: impl IntoIterator<Item = &'a WidgetId>,
) -> Vec<Rect> {
ids.into_iter()
.filter_map(|id| self.region_by_id(id).map(|region| region.area))
.collect()
}
pub fn selectable_at(&self, x: u16, y: u16) -> Option<(&SelectableSpan, SelectionPoint)> {
self.selectable_spans
.iter()
.enumerate()
.filter(|(_, span)| span.copyable && rect_contains(span.screen_area, x, y))
.max_by_key(|(index, _)| *index)
.map(|(_, span)| {
let local_col = x.saturating_sub(span.screen_area.x) as usize;
let col = span
.logical_range
.start_col
.saturating_add(local_col)
.min(span.logical_range.end_col);
let source_offset = span.source_range.start.saturating_add(local_col);
(
span,
SelectionPoint {
source_id: span.source_id.clone(),
group: span.group.clone(),
line: span.logical_range.line,
column: col,
source_offset,
},
)
})
}
pub fn scroll_hit_test(&self, x: u16, y: u16) -> Option<LogicalHit> {
self.scroll_regions.iter().rev().find_map(|region| {
if !rect_contains(region.viewport, x, y) {
return None;
}
let screen_row = y.saturating_sub(region.viewport.y) as usize;
region.rows.get(screen_row).map(|row| LogicalHit {
region_id: region.id.clone(),
row_id: row.row_id.clone(),
logical_row: row.logical_row,
source_line: row.source_line,
span_id: row.span_id.clone(),
item_id: row.item_id.clone(),
wrapped_continuation: row.wrapped_continuation,
screen_row,
local: Position::new(x, y).local_to(region.viewport),
global: Position::new(x, y),
})
})
}
pub fn plain_text_for_source(&self, source_id: &WidgetId) -> String {
self.collect_plain_text(|span| &span.source_id == source_id)
}
pub fn plain_text_for_group(&self, group: &SelectionGroup) -> String {
self.collect_plain_text(|span| span.group.as_ref() == Some(group))
}
pub fn selected_plain_text(&self, range: &SelectionRange) -> String {
let mut lines: BTreeMap<usize, String> = BTreeMap::new();
for span in self.selectable_spans.iter().filter(|span| {
span.copyable && span.source_id == range.source_id && span.group == range.group
}) {
if span.logical_range.line < range.start.line
|| span.logical_range.line > range.end.line
{
continue;
}
let start_col = if span.logical_range.line == range.start.line {
range.start.column
} else {
0
};
let end_col = if span.logical_range.line == range.end.line {
range.end.column
} else {
usize::MAX
};
let clipped_start = span.logical_range.start_col.max(start_col);
let clipped_end = span.logical_range.end_col.min(end_col);
if clipped_start >= clipped_end {
continue;
}
let local_start = clipped_start.saturating_sub(span.logical_range.start_col);
let local_end = clipped_end.saturating_sub(span.logical_range.start_col);
lines
.entry(span.logical_range.line)
.or_default()
.push_str(&slice_display_cols(&span.text, local_start, local_end));
}
lines.into_values().collect::<Vec<_>>().join("\n")
}
fn collect_plain_text(&self, predicate: impl Fn(&SelectableSpan) -> bool) -> String {
let mut spans: Vec<&SelectableSpan> = self
.selectable_spans
.iter()
.filter(|span| span.copyable && predicate(span))
.collect();
spans.sort_by_key(|span| {
(
span.logical_range.line,
span.logical_range.start_col,
span.source_range.start,
)
});
let mut lines: BTreeMap<usize, String> = BTreeMap::new();
for span in spans {
lines
.entry(span.logical_range.line)
.or_default()
.push_str(&span.text);
}
lines.into_values().collect::<Vec<_>>().join("\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionPoint {
pub source_id: WidgetId,
pub group: Option<SelectionGroup>,
pub line: usize,
pub column: usize,
pub source_offset: usize,
}
impl SelectionPoint {
pub fn new(source_id: impl Into<WidgetId>, line: usize, column: usize) -> Self {
Self {
source_id: source_id.into(),
group: None,
line,
column,
source_offset: 0,
}
}
pub fn with_group(mut self, group: impl Into<SelectionGroup>) -> Self {
self.group = Some(group.into());
self
}
pub fn with_source_offset(mut self, source_offset: usize) -> Self {
self.source_offset = source_offset;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectionRange {
pub source_id: WidgetId,
pub group: Option<SelectionGroup>,
pub start: SelectionPoint,
pub end: SelectionPoint,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SelectionModel {
pub anchor: Option<SelectionPoint>,
pub focus: Option<SelectionPoint>,
pub focused_span: Option<SelectionId>,
}
impl SelectionModel {
pub fn new() -> Self {
Self::default()
}
pub fn focus_row(&mut self, span_id: impl Into<SelectionId>) {
self.focused_span = Some(span_id.into());
}
pub fn start(&mut self, point: SelectionPoint) {
self.anchor = Some(point.clone());
self.focus = Some(point);
}
pub fn start_at(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> bool {
if let Some((span, point)) = layer.selectable_at(x, y) {
self.focused_span = Some(span.id.clone());
self.start(point);
true
} else {
false
}
}
pub fn extend_to(&mut self, point: SelectionPoint) {
if self.anchor.is_none() {
self.anchor = Some(point.clone());
}
self.focus = Some(point);
}
pub fn shift_click_at(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> bool {
if let Some((span, point)) = layer.selectable_at(x, y) {
self.focused_span = Some(span.id.clone());
self.extend_to(point);
true
} else {
false
}
}
pub fn update_drag(&mut self, point: SelectionPoint) {
self.focus = Some(point);
}
pub fn update_drag_at(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> bool {
if let Some((span, point)) = layer.selectable_at(x, y) {
self.focused_span = Some(span.id.clone());
self.update_drag(point);
true
} else {
false
}
}
pub fn clear(&mut self) {
self.anchor = None;
self.focus = None;
self.focused_span = None;
}
pub fn is_active(&self) -> bool {
self.anchor.is_some() && self.focus.is_some()
}
pub fn normalized(&self) -> Option<SelectionRange> {
let anchor = self.anchor.as_ref()?;
let focus = self.focus.as_ref()?;
if anchor.source_id != focus.source_id || anchor.group != focus.group {
return None;
}
let (start, end) = if (anchor.line, anchor.column, anchor.source_offset)
<= (focus.line, focus.column, focus.source_offset)
{
(anchor.clone(), focus.clone())
} else {
(focus.clone(), anchor.clone())
};
Some(SelectionRange {
source_id: start.source_id.clone(),
group: start.group.clone(),
start,
end,
})
}
pub fn state(&self) -> SelectionState {
SelectionState {
active: self.is_active(),
anchor: self.anchor.clone(),
focus: self.focus.clone(),
focused_span: self.focused_span.clone(),
}
}
pub fn copied_plain_text(&self, layer: &InteractionLayer) -> String {
self.normalized()
.map(|range| layer.selected_plain_text(&range))
.unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SelectionState {
pub active: bool,
pub anchor: Option<SelectionPoint>,
pub focus: Option<SelectionPoint>,
pub focused_span: Option<SelectionId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HoverEventKind {
Enter,
Leave,
Move,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoverEvent {
pub kind: HoverEventKind,
pub id: Option<WidgetId>,
pub local: Option<Position>,
pub area: Option<Rect>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HoverTransition {
pub events: Vec<HoverEvent>,
pub entered: Option<WidgetId>,
pub left: Option<WidgetId>,
pub dirty: Vec<Rect>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HoverTracker {
hovered: Option<WidgetId>,
}
impl HoverTracker {
pub fn new() -> Self {
Self::default()
}
pub fn hovered(&self) -> Option<&WidgetId> {
self.hovered.as_ref()
}
pub fn update(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> HoverTransition {
let next = layer.hit_test(x, y).map(|region| region.id.clone());
let previous = self.hovered.clone();
if previous == next {
if let Some(id) = next {
let region = layer.region_by_id(&id);
return HoverTransition {
events: vec![HoverEvent {
kind: HoverEventKind::Move,
id: Some(id),
local: region.map(|region| region.local_position(x, y)),
area: region.map(|region| region.area),
}],
..HoverTransition::default()
};
}
return HoverTransition::default();
}
self.hovered = next.clone();
let mut transition = HoverTransition::default();
if let Some(id) = previous {
let region = layer.region_by_id(&id);
if let Some(region) = region {
transition.dirty.push(region.area);
}
transition.left = Some(id.clone());
transition.events.push(HoverEvent {
kind: HoverEventKind::Leave,
id: Some(id),
local: None,
area: region.map(|region| region.area),
});
}
if let Some(id) = next {
let region = layer.region_by_id(&id);
if let Some(region) = region {
transition.dirty.push(region.area);
}
transition.entered = Some(id.clone());
transition.events.push(HoverEvent {
kind: HoverEventKind::Enter,
id: Some(id),
local: region.map(|region| region.local_position(x, y)),
area: region.map(|region| region.area),
});
}
transition
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PointerEventKind {
Move,
Down(MouseButton),
Drag(MouseButton),
Up(MouseButton),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PointerEvent {
pub kind: PointerEventKind,
pub position: Position,
}
impl PointerEvent {
pub const fn new(kind: PointerEventKind, x: u16, y: u16) -> Self {
Self {
kind,
position: Position::new(x, y),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UiEvent {
HoverEnter(WidgetId),
HoverMove {
id: WidgetId,
local: Position,
},
HoverLeave(WidgetId),
MouseDown {
id: WidgetId,
button: MouseButton,
local: Position,
},
MouseUp {
id: WidgetId,
button: MouseButton,
local: Position,
},
Click {
id: WidgetId,
button: MouseButton,
local: Position,
},
DragStart {
id: WidgetId,
local: Position,
},
DragMove {
id: WidgetId,
local: Position,
},
DragEnd {
id: WidgetId,
local: Position,
},
SelectionChanged(SelectionState),
Ignored,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UiEventBatch {
pub events: Vec<UiEvent>,
pub dirty: Vec<Rect>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UiEventMapper {
hover: HoverTracker,
pressed: Option<(WidgetId, MouseButton)>,
dragging: bool,
}
impl UiEventMapper {
pub fn new() -> Self {
Self::default()
}
pub fn hover(&self) -> &HoverTracker {
&self.hover
}
pub fn handle_pointer_event(
&mut self,
layer: &InteractionLayer,
event: PointerEvent,
) -> UiEventBatch {
let x = event.position.x;
let y = event.position.y;
let mut batch = UiEventBatch::default();
if matches!(event.kind, PointerEventKind::Move) {
let transition = self.hover.update(layer, x, y);
batch.dirty.extend(transition.dirty);
for event in transition.events {
match event.kind {
HoverEventKind::Enter => {
if let Some(id) = event.id {
batch.events.push(UiEvent::HoverEnter(id));
}
}
HoverEventKind::Leave => {
if let Some(id) = event.id {
batch.events.push(UiEvent::HoverLeave(id));
}
}
HoverEventKind::Move => {
if let (Some(id), Some(local)) = (event.id, event.local) {
batch.events.push(UiEvent::HoverMove { id, local });
}
}
}
}
return batch;
}
let hit = layer.hit_test(x, y);
match event.kind {
PointerEventKind::Move => unreachable!(),
PointerEventKind::Down(button) => {
if let Some(region) = hit {
let id = region.id.clone();
let local = region.local_position(x, y);
self.pressed = Some((id.clone(), button));
self.dragging = false;
batch.dirty.push(region.area);
batch.events.push(UiEvent::MouseDown { id, button, local });
} else {
batch.events.push(UiEvent::Ignored);
}
}
PointerEventKind::Drag(button) => {
if let Some((id, _)) = self.pressed.clone() {
if let Some(region) = layer.region_by_id(&id) {
let local = region.local_position(x, y);
if !self.dragging {
self.dragging = true;
batch.events.push(UiEvent::DragStart {
id: id.clone(),
local,
});
}
batch.dirty.push(region.area);
batch.events.push(UiEvent::DragMove { id, local });
}
} else if let Some(region) = hit {
batch.events.push(UiEvent::DragMove {
id: region.id.clone(),
local: region.local_position(x, y),
});
if button == MouseButton::WheelUp || button == MouseButton::WheelDown {
batch.dirty.push(region.area);
}
} else {
batch.events.push(UiEvent::Ignored);
}
}
PointerEventKind::Up(button) => {
if let Some((pressed_id, pressed_button)) = self.pressed.take() {
if let Some(region) = layer.region_by_id(&pressed_id) {
let local = region.local_position(x, y);
batch.events.push(UiEvent::MouseUp {
id: pressed_id.clone(),
button,
local,
});
if self.dragging {
batch.events.push(UiEvent::DragEnd {
id: pressed_id.clone(),
local,
});
} else if pressed_button == button && region.contains(x, y) {
batch.events.push(UiEvent::Click {
id: pressed_id.clone(),
button,
local,
});
}
batch.dirty.push(region.area);
}
self.dragging = false;
} else if let Some(region) = hit {
batch.events.push(UiEvent::MouseUp {
id: region.id.clone(),
button,
local: region.local_position(x, y),
});
} else {
batch.events.push(UiEvent::Ignored);
}
}
}
batch
}
}
fn rect_contains(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
}
fn slice_display_cols(text: &str, start: usize, end: usize) -> String {
let mut out = String::new();
let mut col = 0usize;
for ch in text.chars() {
let width = char_display_width(ch).max(1);
let next = col.saturating_add(width);
if next <= start {
col = next;
continue;
}
if col >= end {
break;
}
out.push(ch);
col = next;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hit_test_prefers_highest_z_then_latest_region() {
let mut layer = InteractionLayer::new();
layer.push_region(
HitRegion::new("bottom", Rect::new(0, 0, 4, 1)).with_role(WidgetRole::Panel),
);
layer.push_region(
HitRegion::new("top", Rect::new(1, 0, 1, 1))
.with_role(WidgetRole::Button)
.with_z_index(2),
);
assert_eq!(layer.hit_test(1, 0).unwrap().id.as_ref(), "top");
assert_eq!(layer.hit_test(3, 0).unwrap().id.as_ref(), "bottom");
}
#[test]
fn hover_tracker_emits_enter_leave_and_dirty_rects() {
let mut layer = InteractionLayer::new();
layer.push_region(HitRegion::new("a", Rect::new(0, 0, 2, 1)));
layer.push_region(HitRegion::new("b", Rect::new(3, 0, 2, 1)));
let mut hover = HoverTracker::new();
let first = hover.update(&layer, 0, 0);
assert_eq!(first.entered.as_ref().map(WidgetId::as_ref), Some("a"));
assert_eq!(first.dirty, vec![Rect::new(0, 0, 2, 1)]);
let second = hover.update(&layer, 3, 0);
assert_eq!(second.left.as_ref().map(WidgetId::as_ref), Some("a"));
assert_eq!(second.entered.as_ref().map(WidgetId::as_ref), Some("b"));
assert_eq!(second.dirty.len(), 2);
}
#[test]
fn selection_model_extracts_plain_text() {
let mut layer = InteractionLayer::new();
layer.push_selectable_span(
SelectableSpan::from_logical(
"line0",
"doc",
Rect::new(0, 0, 5, 1),
TextRange::new(0, 0, 5),
"hello",
)
.with_group("doc:body"),
);
layer.push_selectable_span(
SelectableSpan::from_logical(
"line1",
"doc",
Rect::new(0, 1, 5, 1),
TextRange::new(1, 0, 5),
"world",
)
.with_group("doc:body"),
);
let mut selection = SelectionModel::new();
selection.start(SelectionPoint::new("doc", 0, 1).with_group("doc:body"));
selection.update_drag(SelectionPoint::new("doc", 1, 3).with_group("doc:body"));
assert_eq!(selection.copied_plain_text(&layer), "ello\nwor");
}
#[test]
fn scroll_hit_maps_screen_row_to_logical_row() {
let mut layer = InteractionLayer::new();
layer.push_scroll_region(
"transcript",
Rect::new(0, 2, 10, 2),
5,
vec![
ScrollRowHit::new("r5", 5),
ScrollRowHit::new("r6", 6).with_source_line(12),
],
);
let hit = layer.scroll_hit_test(3, 3).unwrap();
assert_eq!(hit.row_id.as_ref(), "r6");
assert_eq!(hit.logical_row, 6);
assert_eq!(hit.source_line, Some(12));
assert_eq!(hit.screen_row, 1);
assert_eq!(hit.local, Position::new(3, 1));
}
#[test]
fn ui_event_mapper_outputs_local_click_coordinates() {
let mut layer = InteractionLayer::new();
layer.push_region(HitRegion::new("button", Rect::new(5, 2, 4, 2)));
let mut mapper = UiEventMapper::new();
let down = mapper.handle_pointer_event(
&layer,
PointerEvent::new(PointerEventKind::Down(MouseButton::Left), 6, 3),
);
assert!(matches!(down.events[0], UiEvent::MouseDown { .. }));
let up = mapper.handle_pointer_event(
&layer,
PointerEvent::new(PointerEventKind::Up(MouseButton::Left), 6, 3),
);
assert!(up.events.iter().any(|event| matches!(
event,
UiEvent::Click { id, local, .. } if id.as_ref() == "button" && *local == Position::new(1, 1)
)));
}
}