Skip to main content

scrin/interaction/
mod.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::ops::Range;
4
5use crate::core::rect::Rect;
6use crate::sanitize::char_display_width;
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub struct WidgetId(pub String);
10
11impl WidgetId {
12    pub fn new(id: impl Into<String>) -> Self {
13        Self(id.into())
14    }
15}
16
17impl AsRef<str> for WidgetId {
18    fn as_ref(&self) -> &str {
19        &self.0
20    }
21}
22
23impl fmt::Display for WidgetId {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.write_str(&self.0)
26    }
27}
28
29impl From<&str> for WidgetId {
30    fn from(value: &str) -> Self {
31        Self::new(value)
32    }
33}
34
35impl From<String> for WidgetId {
36    fn from(value: String) -> Self {
37        Self::new(value)
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct SelectionId(pub String);
43
44impl SelectionId {
45    pub fn new(id: impl Into<String>) -> Self {
46        Self(id.into())
47    }
48}
49
50impl AsRef<str> for SelectionId {
51    fn as_ref(&self) -> &str {
52        &self.0
53    }
54}
55
56impl From<&str> for SelectionId {
57    fn from(value: &str) -> Self {
58        Self::new(value)
59    }
60}
61
62impl From<String> for SelectionId {
63    fn from(value: String) -> Self {
64        Self::new(value)
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct SelectionGroup(pub String);
70
71impl SelectionGroup {
72    pub fn new(id: impl Into<String>) -> Self {
73        Self(id.into())
74    }
75}
76
77impl AsRef<str> for SelectionGroup {
78    fn as_ref(&self) -> &str {
79        &self.0
80    }
81}
82
83impl From<&str> for SelectionGroup {
84    fn from(value: &str) -> Self {
85        Self::new(value)
86    }
87}
88
89impl From<String> for SelectionGroup {
90    fn from(value: String) -> Self {
91        Self::new(value)
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub struct Position {
97    pub x: u16,
98    pub y: u16,
99}
100
101impl Position {
102    pub const fn new(x: u16, y: u16) -> Self {
103        Self { x, y }
104    }
105
106    pub fn local_to(self, area: Rect) -> Self {
107        Self {
108            x: self.x.saturating_sub(area.x),
109            y: self.y.saturating_sub(area.y),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum MouseButton {
116    Left,
117    Right,
118    Middle,
119    WheelUp,
120    WheelDown,
121    Other(u8),
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum MouseCursor {
126    Default,
127    Pointer,
128    Text,
129    Grab,
130    Grabbing,
131    ResizeHorizontal,
132    ResizeVertical,
133    Crosshair,
134    NotAllowed,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub enum WidgetAction {
139    Activate,
140    Focus,
141    Toggle,
142    Open,
143    Copy,
144    CopyCode,
145    Select,
146    Scroll,
147    Drag,
148    Custom(String),
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152pub enum WidgetRole {
153    Unknown,
154    Button,
155    ListItem,
156    Tab,
157    TextSpan,
158    CodeBlock,
159    CodeLine,
160    TodoItem,
161    StatusIndicator,
162    ScrollbarThumb,
163    Link,
164    Panel,
165    Pane,
166    Region,
167    Scrollbar,
168    Status,
169    Text,
170    Toggle,
171    Tooltip,
172    Transcript,
173    TranscriptRow,
174    BoardColumn,
175    BoardCard,
176    Effect,
177    CommandPaletteEntry,
178    ModelRow,
179    RuntimeDiagnostic,
180    Custom(String),
181}
182
183impl Default for WidgetRole {
184    fn default() -> Self {
185        Self::Unknown
186    }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
190pub struct WidgetState {
191    pub focused: bool,
192    pub hovered: bool,
193    pub selected: bool,
194    pub disabled: bool,
195    pub checked: bool,
196    pub active: bool,
197    pub expanded: bool,
198}
199
200impl WidgetState {
201    pub const fn focused(mut self, focused: bool) -> Self {
202        self.focused = focused;
203        self
204    }
205
206    pub const fn hovered(mut self, hovered: bool) -> Self {
207        self.hovered = hovered;
208        self
209    }
210
211    pub const fn selected(mut self, selected: bool) -> Self {
212        self.selected = selected;
213        self
214    }
215
216    pub const fn checked(mut self, checked: bool) -> Self {
217        self.checked = checked;
218        self
219    }
220
221    pub const fn active(mut self, active: bool) -> Self {
222        self.active = active;
223        self
224    }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Hash)]
228pub enum WidgetValue {
229    Text(String),
230    Count(usize),
231    Percent(u16),
232    Status(String),
233    Language(String),
234    LineNumber(usize),
235    Custom(String),
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct HitRegion {
240    pub id: WidgetId,
241    pub area: Rect,
242    pub role: WidgetRole,
243    pub label: String,
244    pub tooltip: Option<String>,
245    pub action: Option<WidgetAction>,
246    pub cursor: Option<MouseCursor>,
247    pub z_index: i16,
248    pub row: Option<usize>,
249    pub column: Option<usize>,
250    pub selection_group: Option<SelectionGroup>,
251    pub description: Option<String>,
252    pub shortcut: Option<String>,
253    pub state: WidgetState,
254    pub value: Option<WidgetValue>,
255}
256
257impl HitRegion {
258    pub fn new(id: impl Into<WidgetId>, area: Rect) -> Self {
259        Self {
260            id: id.into(),
261            area,
262            role: WidgetRole::Unknown,
263            label: String::new(),
264            tooltip: None,
265            action: None,
266            cursor: None,
267            z_index: 0,
268            row: None,
269            column: None,
270            selection_group: None,
271            description: None,
272            shortcut: None,
273            state: WidgetState::default(),
274            value: None,
275        }
276    }
277
278    pub fn with_role(mut self, role: WidgetRole) -> Self {
279        self.role = role;
280        self
281    }
282
283    pub fn with_label(mut self, label: impl Into<String>) -> Self {
284        self.label = label.into();
285        self
286    }
287
288    pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
289        self.tooltip = Some(tooltip.into());
290        self
291    }
292
293    pub fn with_action(mut self, action: WidgetAction) -> Self {
294        self.action = Some(action);
295        self
296    }
297
298    pub fn with_cursor(mut self, cursor: MouseCursor) -> Self {
299        self.cursor = Some(cursor);
300        self
301    }
302
303    pub fn with_z_index(mut self, z_index: i16) -> Self {
304        self.z_index = z_index;
305        self
306    }
307
308    pub fn with_row(mut self, row: usize) -> Self {
309        self.row = Some(row);
310        self
311    }
312
313    pub fn with_column(mut self, column: usize) -> Self {
314        self.column = Some(column);
315        self
316    }
317
318    pub fn with_selection_group(mut self, group: impl Into<SelectionGroup>) -> Self {
319        self.selection_group = Some(group.into());
320        self
321    }
322
323    pub fn with_description(mut self, description: impl Into<String>) -> Self {
324        self.description = Some(description.into());
325        self
326    }
327
328    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
329        self.shortcut = Some(shortcut.into());
330        self
331    }
332
333    pub fn with_state(mut self, state: WidgetState) -> Self {
334        self.state = state;
335        self
336    }
337
338    pub fn with_value(mut self, value: WidgetValue) -> Self {
339        self.value = Some(value);
340        self
341    }
342
343    pub fn contains(&self, x: u16, y: u16) -> bool {
344        rect_contains(self.area, x, y)
345    }
346
347    pub fn local_position(&self, x: u16, y: u16) -> Position {
348        Position::new(x, y).local_to(self.area)
349    }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Default)]
353pub struct WidgetMetadata {
354    pub id: WidgetId,
355    pub role: WidgetRole,
356    pub label: String,
357    pub tooltip: Option<String>,
358    pub action: Option<WidgetAction>,
359    pub cursor: Option<MouseCursor>,
360    pub z_index: i16,
361    pub row: Option<usize>,
362    pub column: Option<usize>,
363    pub selection_group: Option<SelectionGroup>,
364    pub description: Option<String>,
365    pub shortcut: Option<String>,
366    pub state: WidgetState,
367    pub value: Option<WidgetValue>,
368}
369
370impl WidgetMetadata {
371    pub fn new(id: impl Into<WidgetId>) -> Self {
372        Self {
373            id: id.into(),
374            ..Self::default()
375        }
376    }
377
378    pub fn with_role(mut self, role: WidgetRole) -> Self {
379        self.role = role;
380        self
381    }
382
383    pub fn with_label(mut self, label: impl Into<String>) -> Self {
384        self.label = label.into();
385        self
386    }
387
388    pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
389        self.tooltip = Some(tooltip.into());
390        self
391    }
392
393    pub fn with_action(mut self, action: WidgetAction) -> Self {
394        self.action = Some(action);
395        self
396    }
397
398    pub fn with_cursor(mut self, cursor: MouseCursor) -> Self {
399        self.cursor = Some(cursor);
400        self
401    }
402
403    pub fn with_z_index(mut self, z_index: i16) -> Self {
404        self.z_index = z_index;
405        self
406    }
407
408    pub fn with_row(mut self, row: usize) -> Self {
409        self.row = Some(row);
410        self
411    }
412
413    pub fn with_column(mut self, column: usize) -> Self {
414        self.column = Some(column);
415        self
416    }
417
418    pub fn with_selection_group(mut self, group: impl Into<SelectionGroup>) -> Self {
419        self.selection_group = Some(group.into());
420        self
421    }
422
423    pub fn with_description(mut self, description: impl Into<String>) -> Self {
424        self.description = Some(description.into());
425        self
426    }
427
428    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
429        self.shortcut = Some(shortcut.into());
430        self
431    }
432
433    pub fn with_state(mut self, state: WidgetState) -> Self {
434        self.state = state;
435        self
436    }
437
438    pub fn with_value(mut self, value: WidgetValue) -> Self {
439        self.value = Some(value);
440        self
441    }
442
443    pub fn into_region(self, area: Rect) -> HitRegion {
444        HitRegion {
445            id: self.id,
446            area,
447            role: self.role,
448            label: self.label,
449            tooltip: self.tooltip,
450            action: self.action,
451            cursor: self.cursor,
452            z_index: self.z_index,
453            row: self.row,
454            column: self.column,
455            selection_group: self.selection_group,
456            description: self.description,
457            shortcut: self.shortcut,
458            state: self.state,
459            value: self.value,
460        }
461    }
462}
463
464impl Default for WidgetId {
465    fn default() -> Self {
466        Self(String::new())
467    }
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
471pub struct TextRange {
472    pub line: usize,
473    pub start_col: usize,
474    pub end_col: usize,
475}
476
477impl TextRange {
478    pub const fn new(line: usize, start_col: usize, end_col: usize) -> Self {
479        Self {
480            line,
481            start_col,
482            end_col,
483        }
484    }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Hash)]
488pub enum CopyTransform {
489    PlainText,
490    MarkdownSource,
491    CodeOnly,
492    Custom(String),
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct SelectableSpan {
497    pub id: SelectionId,
498    pub text: String,
499    pub source_range: Range<usize>,
500    pub screen_area: Rect,
501    pub group: Option<SelectionGroup>,
502    pub source_id: WidgetId,
503    pub logical_range: TextRange,
504    pub copyable: bool,
505    pub copy_transform: Option<CopyTransform>,
506}
507
508impl SelectableSpan {
509    pub fn new(
510        id: impl Into<SelectionId>,
511        text: impl Into<String>,
512        source_range: Range<usize>,
513        screen_area: Rect,
514    ) -> Self {
515        Self {
516            id: id.into(),
517            text: text.into(),
518            source_range,
519            screen_area,
520            group: None,
521            source_id: WidgetId::default(),
522            logical_range: TextRange::new(0, 0, 0),
523            copyable: true,
524            copy_transform: None,
525        }
526    }
527
528    pub fn from_logical(
529        id: impl Into<SelectionId>,
530        source_id: impl Into<WidgetId>,
531        screen_area: Rect,
532        logical_range: TextRange,
533        text: impl Into<String>,
534    ) -> Self {
535        let text = text.into();
536        let end = text.len();
537        Self {
538            id: id.into(),
539            text,
540            source_range: 0..end,
541            screen_area,
542            group: None,
543            source_id: source_id.into(),
544            logical_range,
545            copyable: true,
546            copy_transform: None,
547        }
548    }
549
550    pub fn with_group(mut self, group: impl Into<SelectionGroup>) -> Self {
551        self.group = Some(group.into());
552        self
553    }
554
555    pub fn with_source_id(mut self, source_id: impl Into<WidgetId>) -> Self {
556        self.source_id = source_id.into();
557        self
558    }
559
560    pub fn with_logical_range(mut self, range: TextRange) -> Self {
561        self.logical_range = range;
562        self
563    }
564
565    pub fn with_copyable(mut self, copyable: bool) -> Self {
566        self.copyable = copyable;
567        self
568    }
569
570    pub fn with_copy_transform(mut self, transform: CopyTransform) -> Self {
571        self.copy_transform = Some(transform);
572        self
573    }
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct ScrollRowHit {
578    pub row_id: WidgetId,
579    pub logical_row: usize,
580    pub source_line: Option<usize>,
581    pub span_id: Option<SelectionId>,
582    pub item_id: Option<WidgetId>,
583    pub wrapped_continuation: bool,
584}
585
586impl ScrollRowHit {
587    pub fn new(row_id: impl Into<WidgetId>, logical_row: usize) -> Self {
588        Self {
589            row_id: row_id.into(),
590            logical_row,
591            source_line: None,
592            span_id: None,
593            item_id: None,
594            wrapped_continuation: false,
595        }
596    }
597
598    pub fn with_source_line(mut self, source_line: usize) -> Self {
599        self.source_line = Some(source_line);
600        self
601    }
602
603    pub fn with_span_id(mut self, span_id: impl Into<SelectionId>) -> Self {
604        self.span_id = Some(span_id.into());
605        self
606    }
607
608    pub fn with_item_id(mut self, item_id: impl Into<WidgetId>) -> Self {
609        self.item_id = Some(item_id.into());
610        self
611    }
612
613    pub fn with_wrapped_continuation(mut self, continuation: bool) -> Self {
614        self.wrapped_continuation = continuation;
615        self
616    }
617}
618
619#[derive(Debug, Clone, PartialEq, Eq)]
620pub struct ScrollHitRegion {
621    pub id: WidgetId,
622    pub viewport: Rect,
623    pub scroll_offset: usize,
624    pub rows: Vec<ScrollRowHit>,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct LogicalHit {
629    pub region_id: WidgetId,
630    pub row_id: WidgetId,
631    pub logical_row: usize,
632    pub source_line: Option<usize>,
633    pub span_id: Option<SelectionId>,
634    pub item_id: Option<WidgetId>,
635    pub wrapped_continuation: bool,
636    pub screen_row: usize,
637    pub local: Position,
638    pub global: Position,
639}
640
641#[derive(Debug, Clone, PartialEq, Eq, Default)]
642pub struct InteractionLayer {
643    pub regions: Vec<HitRegion>,
644    pub selectable_spans: Vec<SelectableSpan>,
645    pub scroll_regions: Vec<ScrollHitRegion>,
646}
647
648impl InteractionLayer {
649    pub fn new() -> Self {
650        Self::default()
651    }
652
653    pub fn clear(&mut self) {
654        self.regions.clear();
655        self.selectable_spans.clear();
656        self.scroll_regions.clear();
657    }
658
659    pub fn push_region(&mut self, region: HitRegion) -> &HitRegion {
660        self.regions.push(region);
661        self.regions.last().expect("region was just pushed")
662    }
663
664    pub fn push_metadata_region(&mut self, area: Rect, metadata: WidgetMetadata) -> &HitRegion {
665        self.push_region(metadata.into_region(area))
666    }
667
668    pub fn push_cell(&mut self, x: u16, y: u16, metadata: WidgetMetadata) -> &HitRegion {
669        self.push_metadata_region(Rect::new(x, y, 1, 1), metadata)
670    }
671
672    pub fn push_selectable_span(&mut self, span: SelectableSpan) -> &SelectableSpan {
673        self.selectable_spans.push(span);
674        self.selectable_spans
675            .last()
676            .expect("selectable span was just pushed")
677    }
678
679    pub fn push_scroll_region(
680        &mut self,
681        id: impl Into<WidgetId>,
682        viewport: Rect,
683        scroll_offset: usize,
684        rows: Vec<ScrollRowHit>,
685    ) -> &ScrollHitRegion {
686        self.scroll_regions.push(ScrollHitRegion {
687            id: id.into(),
688            viewport,
689            scroll_offset,
690            rows,
691        });
692        self.scroll_regions
693            .last()
694            .expect("scroll region was just pushed")
695    }
696
697    pub fn hit_test(&self, x: u16, y: u16) -> Option<&HitRegion> {
698        self.regions
699            .iter()
700            .enumerate()
701            .filter(|(_, region)| region.contains(x, y))
702            .max_by_key(|(index, region)| (region.z_index, *index))
703            .map(|(_, region)| region)
704    }
705
706    pub fn hit_test_position(&self, position: Position) -> Option<&HitRegion> {
707        self.hit_test(position.x, position.y)
708    }
709
710    pub fn region_by_id(&self, id: &WidgetId) -> Option<&HitRegion> {
711        self.regions.iter().rev().find(|region| &region.id == id)
712    }
713
714    pub fn regions_for_role(&self, role: WidgetRole) -> impl Iterator<Item = &HitRegion> {
715        self.regions
716            .iter()
717            .filter(move |region| region.role == role)
718    }
719
720    pub fn dirty_regions_for_ids<'a>(
721        &'a self,
722        ids: impl IntoIterator<Item = &'a WidgetId>,
723    ) -> Vec<Rect> {
724        ids.into_iter()
725            .filter_map(|id| self.region_by_id(id).map(|region| region.area))
726            .collect()
727    }
728
729    pub fn selectable_at(&self, x: u16, y: u16) -> Option<(&SelectableSpan, SelectionPoint)> {
730        self.selectable_spans
731            .iter()
732            .enumerate()
733            .filter(|(_, span)| span.copyable && rect_contains(span.screen_area, x, y))
734            .max_by_key(|(index, _)| *index)
735            .map(|(_, span)| {
736                let local_col = x.saturating_sub(span.screen_area.x) as usize;
737                let col = span
738                    .logical_range
739                    .start_col
740                    .saturating_add(local_col)
741                    .min(span.logical_range.end_col);
742                let source_offset = span.source_range.start.saturating_add(local_col);
743                (
744                    span,
745                    SelectionPoint {
746                        source_id: span.source_id.clone(),
747                        group: span.group.clone(),
748                        line: span.logical_range.line,
749                        column: col,
750                        source_offset,
751                    },
752                )
753            })
754    }
755
756    pub fn scroll_hit_test(&self, x: u16, y: u16) -> Option<LogicalHit> {
757        self.scroll_regions.iter().rev().find_map(|region| {
758            if !rect_contains(region.viewport, x, y) {
759                return None;
760            }
761            let screen_row = y.saturating_sub(region.viewport.y) as usize;
762            region.rows.get(screen_row).map(|row| LogicalHit {
763                region_id: region.id.clone(),
764                row_id: row.row_id.clone(),
765                logical_row: row.logical_row,
766                source_line: row.source_line,
767                span_id: row.span_id.clone(),
768                item_id: row.item_id.clone(),
769                wrapped_continuation: row.wrapped_continuation,
770                screen_row,
771                local: Position::new(x, y).local_to(region.viewport),
772                global: Position::new(x, y),
773            })
774        })
775    }
776
777    pub fn plain_text_for_source(&self, source_id: &WidgetId) -> String {
778        self.collect_plain_text(|span| &span.source_id == source_id)
779    }
780
781    pub fn plain_text_for_group(&self, group: &SelectionGroup) -> String {
782        self.collect_plain_text(|span| span.group.as_ref() == Some(group))
783    }
784
785    pub fn selected_plain_text(&self, range: &SelectionRange) -> String {
786        let mut lines: BTreeMap<usize, String> = BTreeMap::new();
787        for span in self.selectable_spans.iter().filter(|span| {
788            span.copyable && span.source_id == range.source_id && span.group == range.group
789        }) {
790            if span.logical_range.line < range.start.line
791                || span.logical_range.line > range.end.line
792            {
793                continue;
794            }
795            let start_col = if span.logical_range.line == range.start.line {
796                range.start.column
797            } else {
798                0
799            };
800            let end_col = if span.logical_range.line == range.end.line {
801                range.end.column
802            } else {
803                usize::MAX
804            };
805            let clipped_start = span.logical_range.start_col.max(start_col);
806            let clipped_end = span.logical_range.end_col.min(end_col);
807            if clipped_start >= clipped_end {
808                continue;
809            }
810            let local_start = clipped_start.saturating_sub(span.logical_range.start_col);
811            let local_end = clipped_end.saturating_sub(span.logical_range.start_col);
812            lines
813                .entry(span.logical_range.line)
814                .or_default()
815                .push_str(&slice_display_cols(&span.text, local_start, local_end));
816        }
817        lines.into_values().collect::<Vec<_>>().join("\n")
818    }
819
820    fn collect_plain_text(&self, predicate: impl Fn(&SelectableSpan) -> bool) -> String {
821        let mut spans: Vec<&SelectableSpan> = self
822            .selectable_spans
823            .iter()
824            .filter(|span| span.copyable && predicate(span))
825            .collect();
826        spans.sort_by_key(|span| {
827            (
828                span.logical_range.line,
829                span.logical_range.start_col,
830                span.source_range.start,
831            )
832        });
833
834        let mut lines: BTreeMap<usize, String> = BTreeMap::new();
835        for span in spans {
836            lines
837                .entry(span.logical_range.line)
838                .or_default()
839                .push_str(&span.text);
840        }
841        lines.into_values().collect::<Vec<_>>().join("\n")
842    }
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Hash)]
846pub struct SelectionPoint {
847    pub source_id: WidgetId,
848    pub group: Option<SelectionGroup>,
849    pub line: usize,
850    pub column: usize,
851    pub source_offset: usize,
852}
853
854impl SelectionPoint {
855    pub fn new(source_id: impl Into<WidgetId>, line: usize, column: usize) -> Self {
856        Self {
857            source_id: source_id.into(),
858            group: None,
859            line,
860            column,
861            source_offset: 0,
862        }
863    }
864
865    pub fn with_group(mut self, group: impl Into<SelectionGroup>) -> Self {
866        self.group = Some(group.into());
867        self
868    }
869
870    pub fn with_source_offset(mut self, source_offset: usize) -> Self {
871        self.source_offset = source_offset;
872        self
873    }
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct SelectionRange {
878    pub source_id: WidgetId,
879    pub group: Option<SelectionGroup>,
880    pub start: SelectionPoint,
881    pub end: SelectionPoint,
882}
883
884#[derive(Debug, Clone, PartialEq, Eq, Default)]
885pub struct SelectionModel {
886    pub anchor: Option<SelectionPoint>,
887    pub focus: Option<SelectionPoint>,
888    pub focused_span: Option<SelectionId>,
889}
890
891impl SelectionModel {
892    pub fn new() -> Self {
893        Self::default()
894    }
895
896    pub fn focus_row(&mut self, span_id: impl Into<SelectionId>) {
897        self.focused_span = Some(span_id.into());
898    }
899
900    pub fn start(&mut self, point: SelectionPoint) {
901        self.anchor = Some(point.clone());
902        self.focus = Some(point);
903    }
904
905    pub fn start_at(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> bool {
906        if let Some((span, point)) = layer.selectable_at(x, y) {
907            self.focused_span = Some(span.id.clone());
908            self.start(point);
909            true
910        } else {
911            false
912        }
913    }
914
915    pub fn extend_to(&mut self, point: SelectionPoint) {
916        if self.anchor.is_none() {
917            self.anchor = Some(point.clone());
918        }
919        self.focus = Some(point);
920    }
921
922    pub fn shift_click_at(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> bool {
923        if let Some((span, point)) = layer.selectable_at(x, y) {
924            self.focused_span = Some(span.id.clone());
925            self.extend_to(point);
926            true
927        } else {
928            false
929        }
930    }
931
932    pub fn update_drag(&mut self, point: SelectionPoint) {
933        self.focus = Some(point);
934    }
935
936    pub fn update_drag_at(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> bool {
937        if let Some((span, point)) = layer.selectable_at(x, y) {
938            self.focused_span = Some(span.id.clone());
939            self.update_drag(point);
940            true
941        } else {
942            false
943        }
944    }
945
946    pub fn clear(&mut self) {
947        self.anchor = None;
948        self.focus = None;
949        self.focused_span = None;
950    }
951
952    pub fn is_active(&self) -> bool {
953        self.anchor.is_some() && self.focus.is_some()
954    }
955
956    pub fn normalized(&self) -> Option<SelectionRange> {
957        let anchor = self.anchor.as_ref()?;
958        let focus = self.focus.as_ref()?;
959        if anchor.source_id != focus.source_id || anchor.group != focus.group {
960            return None;
961        }
962        let (start, end) = if (anchor.line, anchor.column, anchor.source_offset)
963            <= (focus.line, focus.column, focus.source_offset)
964        {
965            (anchor.clone(), focus.clone())
966        } else {
967            (focus.clone(), anchor.clone())
968        };
969        Some(SelectionRange {
970            source_id: start.source_id.clone(),
971            group: start.group.clone(),
972            start,
973            end,
974        })
975    }
976
977    pub fn state(&self) -> SelectionState {
978        SelectionState {
979            active: self.is_active(),
980            anchor: self.anchor.clone(),
981            focus: self.focus.clone(),
982            focused_span: self.focused_span.clone(),
983        }
984    }
985
986    pub fn copied_plain_text(&self, layer: &InteractionLayer) -> String {
987        self.normalized()
988            .map(|range| layer.selected_plain_text(&range))
989            .unwrap_or_default()
990    }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq, Default)]
994pub struct SelectionState {
995    pub active: bool,
996    pub anchor: Option<SelectionPoint>,
997    pub focus: Option<SelectionPoint>,
998    pub focused_span: Option<SelectionId>,
999}
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1002pub enum HoverEventKind {
1003    Enter,
1004    Leave,
1005    Move,
1006}
1007
1008#[derive(Debug, Clone, PartialEq, Eq)]
1009pub struct HoverEvent {
1010    pub kind: HoverEventKind,
1011    pub id: Option<WidgetId>,
1012    pub local: Option<Position>,
1013    pub area: Option<Rect>,
1014}
1015
1016#[derive(Debug, Clone, PartialEq, Eq, Default)]
1017pub struct HoverTransition {
1018    pub events: Vec<HoverEvent>,
1019    pub entered: Option<WidgetId>,
1020    pub left: Option<WidgetId>,
1021    pub dirty: Vec<Rect>,
1022}
1023
1024#[derive(Debug, Clone, PartialEq, Eq, Default)]
1025pub struct HoverTracker {
1026    hovered: Option<WidgetId>,
1027}
1028
1029impl HoverTracker {
1030    pub fn new() -> Self {
1031        Self::default()
1032    }
1033
1034    pub fn hovered(&self) -> Option<&WidgetId> {
1035        self.hovered.as_ref()
1036    }
1037
1038    pub fn update(&mut self, layer: &InteractionLayer, x: u16, y: u16) -> HoverTransition {
1039        let next = layer.hit_test(x, y).map(|region| region.id.clone());
1040        let previous = self.hovered.clone();
1041        if previous == next {
1042            if let Some(id) = next {
1043                let region = layer.region_by_id(&id);
1044                return HoverTransition {
1045                    events: vec![HoverEvent {
1046                        kind: HoverEventKind::Move,
1047                        id: Some(id),
1048                        local: region.map(|region| region.local_position(x, y)),
1049                        area: region.map(|region| region.area),
1050                    }],
1051                    ..HoverTransition::default()
1052                };
1053            }
1054            return HoverTransition::default();
1055        }
1056
1057        self.hovered = next.clone();
1058        let mut transition = HoverTransition::default();
1059        if let Some(id) = previous {
1060            let region = layer.region_by_id(&id);
1061            if let Some(region) = region {
1062                transition.dirty.push(region.area);
1063            }
1064            transition.left = Some(id.clone());
1065            transition.events.push(HoverEvent {
1066                kind: HoverEventKind::Leave,
1067                id: Some(id),
1068                local: None,
1069                area: region.map(|region| region.area),
1070            });
1071        }
1072        if let Some(id) = next {
1073            let region = layer.region_by_id(&id);
1074            if let Some(region) = region {
1075                transition.dirty.push(region.area);
1076            }
1077            transition.entered = Some(id.clone());
1078            transition.events.push(HoverEvent {
1079                kind: HoverEventKind::Enter,
1080                id: Some(id),
1081                local: region.map(|region| region.local_position(x, y)),
1082                area: region.map(|region| region.area),
1083            });
1084        }
1085        transition
1086    }
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090pub enum PointerEventKind {
1091    Move,
1092    Down(MouseButton),
1093    Drag(MouseButton),
1094    Up(MouseButton),
1095}
1096
1097#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1098pub struct PointerEvent {
1099    pub kind: PointerEventKind,
1100    pub position: Position,
1101}
1102
1103impl PointerEvent {
1104    pub const fn new(kind: PointerEventKind, x: u16, y: u16) -> Self {
1105        Self {
1106            kind,
1107            position: Position::new(x, y),
1108        }
1109    }
1110}
1111
1112#[derive(Debug, Clone, PartialEq, Eq)]
1113pub enum UiEvent {
1114    HoverEnter(WidgetId),
1115    HoverMove {
1116        id: WidgetId,
1117        local: Position,
1118    },
1119    HoverLeave(WidgetId),
1120    MouseDown {
1121        id: WidgetId,
1122        button: MouseButton,
1123        local: Position,
1124    },
1125    MouseUp {
1126        id: WidgetId,
1127        button: MouseButton,
1128        local: Position,
1129    },
1130    Click {
1131        id: WidgetId,
1132        button: MouseButton,
1133        local: Position,
1134    },
1135    DragStart {
1136        id: WidgetId,
1137        local: Position,
1138    },
1139    DragMove {
1140        id: WidgetId,
1141        local: Position,
1142    },
1143    DragEnd {
1144        id: WidgetId,
1145        local: Position,
1146    },
1147    SelectionChanged(SelectionState),
1148    Ignored,
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Eq, Default)]
1152pub struct UiEventBatch {
1153    pub events: Vec<UiEvent>,
1154    pub dirty: Vec<Rect>,
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Eq, Default)]
1158pub struct UiEventMapper {
1159    hover: HoverTracker,
1160    pressed: Option<(WidgetId, MouseButton)>,
1161    dragging: bool,
1162}
1163
1164impl UiEventMapper {
1165    pub fn new() -> Self {
1166        Self::default()
1167    }
1168
1169    pub fn hover(&self) -> &HoverTracker {
1170        &self.hover
1171    }
1172
1173    pub fn handle_pointer_event(
1174        &mut self,
1175        layer: &InteractionLayer,
1176        event: PointerEvent,
1177    ) -> UiEventBatch {
1178        let x = event.position.x;
1179        let y = event.position.y;
1180        let mut batch = UiEventBatch::default();
1181
1182        if matches!(event.kind, PointerEventKind::Move) {
1183            let transition = self.hover.update(layer, x, y);
1184            batch.dirty.extend(transition.dirty);
1185            for event in transition.events {
1186                match event.kind {
1187                    HoverEventKind::Enter => {
1188                        if let Some(id) = event.id {
1189                            batch.events.push(UiEvent::HoverEnter(id));
1190                        }
1191                    }
1192                    HoverEventKind::Leave => {
1193                        if let Some(id) = event.id {
1194                            batch.events.push(UiEvent::HoverLeave(id));
1195                        }
1196                    }
1197                    HoverEventKind::Move => {
1198                        if let (Some(id), Some(local)) = (event.id, event.local) {
1199                            batch.events.push(UiEvent::HoverMove { id, local });
1200                        }
1201                    }
1202                }
1203            }
1204            return batch;
1205        }
1206
1207        let hit = layer.hit_test(x, y);
1208        match event.kind {
1209            PointerEventKind::Move => unreachable!(),
1210            PointerEventKind::Down(button) => {
1211                if let Some(region) = hit {
1212                    let id = region.id.clone();
1213                    let local = region.local_position(x, y);
1214                    self.pressed = Some((id.clone(), button));
1215                    self.dragging = false;
1216                    batch.dirty.push(region.area);
1217                    batch.events.push(UiEvent::MouseDown { id, button, local });
1218                } else {
1219                    batch.events.push(UiEvent::Ignored);
1220                }
1221            }
1222            PointerEventKind::Drag(button) => {
1223                if let Some((id, _)) = self.pressed.clone() {
1224                    if let Some(region) = layer.region_by_id(&id) {
1225                        let local = region.local_position(x, y);
1226                        if !self.dragging {
1227                            self.dragging = true;
1228                            batch.events.push(UiEvent::DragStart {
1229                                id: id.clone(),
1230                                local,
1231                            });
1232                        }
1233                        batch.dirty.push(region.area);
1234                        batch.events.push(UiEvent::DragMove { id, local });
1235                    }
1236                } else if let Some(region) = hit {
1237                    batch.events.push(UiEvent::DragMove {
1238                        id: region.id.clone(),
1239                        local: region.local_position(x, y),
1240                    });
1241                    if button == MouseButton::WheelUp || button == MouseButton::WheelDown {
1242                        batch.dirty.push(region.area);
1243                    }
1244                } else {
1245                    batch.events.push(UiEvent::Ignored);
1246                }
1247            }
1248            PointerEventKind::Up(button) => {
1249                if let Some((pressed_id, pressed_button)) = self.pressed.take() {
1250                    if let Some(region) = layer.region_by_id(&pressed_id) {
1251                        let local = region.local_position(x, y);
1252                        batch.events.push(UiEvent::MouseUp {
1253                            id: pressed_id.clone(),
1254                            button,
1255                            local,
1256                        });
1257                        if self.dragging {
1258                            batch.events.push(UiEvent::DragEnd {
1259                                id: pressed_id.clone(),
1260                                local,
1261                            });
1262                        } else if pressed_button == button && region.contains(x, y) {
1263                            batch.events.push(UiEvent::Click {
1264                                id: pressed_id.clone(),
1265                                button,
1266                                local,
1267                            });
1268                        }
1269                        batch.dirty.push(region.area);
1270                    }
1271                    self.dragging = false;
1272                } else if let Some(region) = hit {
1273                    batch.events.push(UiEvent::MouseUp {
1274                        id: region.id.clone(),
1275                        button,
1276                        local: region.local_position(x, y),
1277                    });
1278                } else {
1279                    batch.events.push(UiEvent::Ignored);
1280                }
1281            }
1282        }
1283
1284        batch
1285    }
1286}
1287
1288fn rect_contains(rect: Rect, x: u16, y: u16) -> bool {
1289    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
1290}
1291
1292fn slice_display_cols(text: &str, start: usize, end: usize) -> String {
1293    let mut out = String::new();
1294    let mut col = 0usize;
1295    for ch in text.chars() {
1296        let width = char_display_width(ch).max(1);
1297        let next = col.saturating_add(width);
1298        if next <= start {
1299            col = next;
1300            continue;
1301        }
1302        if col >= end {
1303            break;
1304        }
1305        out.push(ch);
1306        col = next;
1307    }
1308    out
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313    use super::*;
1314
1315    #[test]
1316    fn hit_test_prefers_highest_z_then_latest_region() {
1317        let mut layer = InteractionLayer::new();
1318        layer.push_region(
1319            HitRegion::new("bottom", Rect::new(0, 0, 4, 1)).with_role(WidgetRole::Panel),
1320        );
1321        layer.push_region(
1322            HitRegion::new("top", Rect::new(1, 0, 1, 1))
1323                .with_role(WidgetRole::Button)
1324                .with_z_index(2),
1325        );
1326
1327        assert_eq!(layer.hit_test(1, 0).unwrap().id.as_ref(), "top");
1328        assert_eq!(layer.hit_test(3, 0).unwrap().id.as_ref(), "bottom");
1329    }
1330
1331    #[test]
1332    fn hover_tracker_emits_enter_leave_and_dirty_rects() {
1333        let mut layer = InteractionLayer::new();
1334        layer.push_region(HitRegion::new("a", Rect::new(0, 0, 2, 1)));
1335        layer.push_region(HitRegion::new("b", Rect::new(3, 0, 2, 1)));
1336        let mut hover = HoverTracker::new();
1337
1338        let first = hover.update(&layer, 0, 0);
1339        assert_eq!(first.entered.as_ref().map(WidgetId::as_ref), Some("a"));
1340        assert_eq!(first.dirty, vec![Rect::new(0, 0, 2, 1)]);
1341
1342        let second = hover.update(&layer, 3, 0);
1343        assert_eq!(second.left.as_ref().map(WidgetId::as_ref), Some("a"));
1344        assert_eq!(second.entered.as_ref().map(WidgetId::as_ref), Some("b"));
1345        assert_eq!(second.dirty.len(), 2);
1346    }
1347
1348    #[test]
1349    fn selection_model_extracts_plain_text() {
1350        let mut layer = InteractionLayer::new();
1351        layer.push_selectable_span(
1352            SelectableSpan::from_logical(
1353                "line0",
1354                "doc",
1355                Rect::new(0, 0, 5, 1),
1356                TextRange::new(0, 0, 5),
1357                "hello",
1358            )
1359            .with_group("doc:body"),
1360        );
1361        layer.push_selectable_span(
1362            SelectableSpan::from_logical(
1363                "line1",
1364                "doc",
1365                Rect::new(0, 1, 5, 1),
1366                TextRange::new(1, 0, 5),
1367                "world",
1368            )
1369            .with_group("doc:body"),
1370        );
1371
1372        let mut selection = SelectionModel::new();
1373        selection.start(SelectionPoint::new("doc", 0, 1).with_group("doc:body"));
1374        selection.update_drag(SelectionPoint::new("doc", 1, 3).with_group("doc:body"));
1375
1376        assert_eq!(selection.copied_plain_text(&layer), "ello\nwor");
1377    }
1378
1379    #[test]
1380    fn scroll_hit_maps_screen_row_to_logical_row() {
1381        let mut layer = InteractionLayer::new();
1382        layer.push_scroll_region(
1383            "transcript",
1384            Rect::new(0, 2, 10, 2),
1385            5,
1386            vec![
1387                ScrollRowHit::new("r5", 5),
1388                ScrollRowHit::new("r6", 6).with_source_line(12),
1389            ],
1390        );
1391
1392        let hit = layer.scroll_hit_test(3, 3).unwrap();
1393        assert_eq!(hit.row_id.as_ref(), "r6");
1394        assert_eq!(hit.logical_row, 6);
1395        assert_eq!(hit.source_line, Some(12));
1396        assert_eq!(hit.screen_row, 1);
1397        assert_eq!(hit.local, Position::new(3, 1));
1398    }
1399
1400    #[test]
1401    fn ui_event_mapper_outputs_local_click_coordinates() {
1402        let mut layer = InteractionLayer::new();
1403        layer.push_region(HitRegion::new("button", Rect::new(5, 2, 4, 2)));
1404        let mut mapper = UiEventMapper::new();
1405
1406        let down = mapper.handle_pointer_event(
1407            &layer,
1408            PointerEvent::new(PointerEventKind::Down(MouseButton::Left), 6, 3),
1409        );
1410        assert!(matches!(down.events[0], UiEvent::MouseDown { .. }));
1411
1412        let up = mapper.handle_pointer_event(
1413            &layer,
1414            PointerEvent::new(PointerEventKind::Up(MouseButton::Left), 6, 3),
1415        );
1416        assert!(up.events.iter().any(|event| matches!(
1417            event,
1418            UiEvent::Click { id, local, .. } if id.as_ref() == "button" && *local == Position::new(1, 1)
1419        )));
1420    }
1421}