Skip to main content

rotulus_layout/
focus.rs

1//! What keyboard focus steps through.
2//!
3//! The view draws a transcript, not widgets, so Tab has nothing of GTK's
4//! to move between. These are the things a click does something to — a
5//! speaker, a link, a load-more row, an image — in reading order, so that
6//! the keyboard reaches each of them, in the order they are read, and
7//! nothing else.
8
9use std::ops::Range;
10
11use crate::buffer::ChatBuffer;
12use crate::message::{Block, MessageFlags, MessageId, MessageKind};
13use crate::wrap::LineSource;
14
15/// One thing in the scrollback that does something when activated.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum FocusItem {
18    /// A speaker's name in the gutter, and their avatar with it.
19    Speaker { message: MessageId },
20    /// A link: the text it lives in and its byte range there.
21    Link {
22        message: MessageId,
23        source: LineSource,
24        range: Range<usize>,
25    },
26    /// A load-more row, all of it.
27    LoadMore { message: MessageId },
28    /// An inline image, or the placeholder standing in for one.
29    Image { message: MessageId, block: usize },
30}
31
32impl FocusItem {
33    /// The row the item is on.
34    pub fn message(&self) -> MessageId {
35        match self {
36            FocusItem::Speaker { message }
37            | FocusItem::Link { message, .. }
38            | FocusItem::LoadMore { message }
39            | FocusItem::Image { message, .. } => *message,
40        }
41    }
42}
43
44impl ChatBuffer {
45    /// A row's items, in reading order: the speaker, then each block's
46    /// links or image in turn. A load-more row is one item, whatever it
47    /// says.
48    pub fn focus_items(&self, row: usize) -> Vec<FocusItem> {
49        let (Some(message), Some(msg)) = (self.id_at(row), self.message_at(row)) else {
50            return Vec::new();
51        };
52        if let MessageKind::LoadMore(_) = msg.kind {
53            return vec![FocusItem::LoadMore { message }];
54        }
55        let mut out = Vec::new();
56        // The gutter a grouped row doesn't draw can't be clicked either.
57        let shown = !msg.flags.contains(MessageFlags::GROUPED)
58            && (msg.gutter.as_ref().is_some_and(|g| !g.text.is_empty())
59                || self.params().avatar_size > 0);
60        if shown && msg.speaker.as_ref().is_some_and(|s| s.key != 0) {
61            out.push(FocusItem::Speaker { message });
62        }
63        for (bi, b) in msg.blocks.iter().enumerate() {
64            let parsed = match b {
65                Block::Text(p) | Block::Quote { content: p, .. } => p,
66                Block::Image { .. } => {
67                    out.push(FocusItem::Image { message, block: bi });
68                    continue;
69                }
70                Block::Code { .. } => continue,
71            };
72            let mut links: Vec<_> = parsed.links.iter().map(|l| l.range.clone()).collect();
73            links.sort_by_key(|r| r.start);
74            out.extend(links.into_iter().map(|range| FocusItem::Link {
75                message,
76                source: LineSource::Block(bi),
77                range,
78            }));
79        }
80        out
81    }
82
83    /// The item after `from` (or before it, going back) among the rows
84    /// `rows` spans, inclusive — what is on screen — crossing rows as
85    /// needed. Without `from`, or when it isn't in those rows, the first
86    /// item there (or the last, going back). `None` past either end, so
87    /// focus leaves rather than walking the whole scrollback.
88    pub fn step_focus(
89        &self,
90        from: Option<&FocusItem>,
91        forward: bool,
92        rows: (usize, usize),
93    ) -> Option<FocusItem> {
94        let (first, last) = (rows.0, rows.1.min(self.len().checked_sub(1)?));
95        let shown = |r: usize| r >= first && r <= last;
96        let mut row = if forward { first } else { last };
97        if let Some(f) = from {
98            if let Some(r) = self.row_of(f.message()).filter(|r| shown(*r)) {
99                let items = self.focus_items(r);
100                if let Some(at) = items.iter().position(|i| i == f) {
101                    let next = if forward {
102                        items.get(at + 1)
103                    } else {
104                        at.checked_sub(1).and_then(|i| items.get(i))
105                    };
106                    if let Some(next) = next {
107                        return Some(next.clone());
108                    }
109                    row = if forward { r + 1 } else { r.checked_sub(1)? };
110                }
111            }
112        }
113        while shown(row) {
114            let items = self.focus_items(row);
115            let pick = if forward {
116                items.into_iter().next()
117            } else {
118                items.into_iter().next_back()
119            };
120            if pick.is_some() {
121                return pick;
122            }
123            row = if forward {
124                row + 1
125            } else {
126                row.checked_sub(1)?
127            };
128        }
129        None
130    }
131}