Skip to main content

gpui_kit_grid/state/
link.rs

1//! Links in cells: a [`CellValue::Link`] painted by the body as underlined
2//! text in the theme's link colour, the pointer cursor over its text, and
3//! what opens it: a click on its text, Enter on the focused cell
4//! (`OpenLink`, bound in `LiveGrid && cell == link`). What opening means is
5//! the [`LinkOpener`] seam; the default opens the address in the system's
6//! browser, or a new tab on the web.
7//!
8//! A link is the one place in the grid where the pointer cursor is right
9//! (gpui-kit's rule: buttons keep the default cursor, links do not). The
10//! frame records where each link's text was painted; the pointer is over
11//! a link when it is over one of those, as of the last frame.
12
13use std::rc::Rc;
14
15use gpui_kit::{App, Bounds, Context, Modifiers, MouseButton, Pixels, Point, Window};
16
17use super::{ColumnOf, KeyOf, LiveGridDelegate, LiveGridState, menu::GridTarget};
18use crate::{
19    actions::OpenLink,
20    model::{CellValue, GridRow},
21};
22
23/// Opens a link of the grid: a click on a [`CellValue::Link`]'s text, or
24/// Enter on its focused cell.
25///
26/// Called once per gesture, after the grid's update (`App::defer`): it may
27/// read and update the grid.
28pub trait LinkOpener: 'static {
29    /// Opens `url`, the address of the link that was activated.
30    fn open(&self, url: &str, cx: &mut App);
31}
32
33/// Opens the address with `App::open_url`: natively in the system's
34/// browser, on the web in a new tab. The default.
35#[derive(Clone, Copy, Debug, Default)]
36pub struct OpenInBrowser;
37
38impl LinkOpener for OpenInBrowser {
39    fn open(&self, url: &str, cx: &mut App) {
40        cx.open_url(url);
41    }
42}
43
44/// What a grid keeps for its links: how it opens them, where the last
45/// frame painted their texts, and whether the pointer is over one.
46pub(super) struct LinkSlot {
47    opener: Rc<dyn LinkOpener>,
48    /// The painted texts of links in window coordinates, the cell's height.
49    pub(super) rects: Vec<Bounds<Pixels>>,
50    /// The pointer is over a link's text: the body shows the pointer.
51    pub(super) hovered: bool,
52    /// The press series began on a link's text: its second press is no
53    /// `Confirm`, the first opened the link.
54    pub(super) series_on_link: bool,
55}
56
57impl Default for LinkSlot {
58    fn default() -> Self {
59        Self {
60            opener: Rc::new(OpenInBrowser),
61            rects: Vec::new(),
62            hovered: false,
63            series_on_link: false,
64        }
65    }
66}
67
68impl LinkSlot {
69    /// Returns true if `position` is over a painted link's text.
70    pub(super) fn is_over(&self, position: Point<Pixels>) -> bool {
71        self.rects.iter().any(|rect| rect.contains(&position))
72    }
73}
74
75impl<D: LiveGridDelegate> LiveGridState<D> {
76    /// Sets what opening a link does (default [`OpenInBrowser`]): an
77    /// application may open it in a view of its own, or ask first.
78    pub fn with_link_opener(mut self, opener: impl LinkOpener) -> Self {
79        self.links.opener = Rc::new(opener);
80        self
81    }
82
83    /// Returns true if the pointer is over a link's text, as of the last
84    /// frame: the body shows the pointer cursor.
85    pub fn is_link_hovered(&self) -> bool {
86        self.links.hovered
87    }
88
89    /// Returns true if the pointer is over a link's text now.
90    pub(super) fn is_pointer_on_link(&self) -> bool {
91        self.tooltip
92            .position
93            .is_some_and(|position| self.links.is_over(position))
94    }
95
96    /// The pointer moved to `position` (`None`: it left the body), or a
97    /// frame painted links anew: the pointer cursor follows.
98    pub(super) fn update_link_hover(
99        &mut self,
100        position: Option<Point<Pixels>>,
101        cx: &mut Context<Self>,
102    ) {
103        let hovered = position.is_some_and(|position| self.links.is_over(position));
104        if hovered != self.links.hovered {
105            self.links.hovered = hovered;
106            cx.notify();
107        }
108    }
109
110    /// The address of the link in the cell `target` names, if its value is
111    /// a link now.
112    fn link_url(&self, target: &GridTarget<KeyOf<D>, ColumnOf<D>>) -> Option<String> {
113        let (row, column) = match target {
114            GridTarget::Cell { key, column } => (self.snapshot.get(key)?.clone(), *column),
115            GridTarget::Pinned { edge, key, column } => (
116                self.snapshot
117                    .pinned_rows(*edge)
118                    .iter()
119                    .find(|row| row.key() == key)?
120                    .clone(),
121                *column,
122            ),
123            _ => return None,
124        };
125        match row.value(column) {
126            CellValue::Link(link) => Some(link.url().to_owned()),
127            _ => None,
128        }
129    }
130
131    /// Opens the link of `target` after this update, if it has one.
132    fn open_link_of(
133        &self,
134        target: &GridTarget<KeyOf<D>, ColumnOf<D>>,
135        cx: &mut Context<Self>,
136    ) -> bool {
137        let Some(url) = self.link_url(target) else {
138            return false;
139        };
140        let opener = self.links.opener.clone();
141        cx.defer(move |cx| opener.open(&url, cx));
142        true
143    }
144
145    /// A click (`LiveGridEvent::Click`) that pressed and released the left
146    /// button once, without modifiers, on a link's painted text opens it.
147    pub(super) fn open_clicked_link(
148        &self,
149        target: &GridTarget<KeyOf<D>, ColumnOf<D>>,
150        button: MouseButton,
151        modifiers: Modifiers,
152        click_count: usize,
153        origin: Point<Pixels>,
154        cx: &mut Context<Self>,
155    ) {
156        if button == MouseButton::Left
157            && click_count == 1
158            && !modifiers.modified()
159            && self.links.is_over(origin)
160        {
161            self.open_link_of(target, cx);
162        }
163    }
164
165    /// Returns true if the focused cell of a data or pinned row holds a
166    /// link now: the key context's `cell` is `link`.
167    pub(super) fn is_link_focused(&self) -> bool {
168        self.focused_link_target()
169            .is_some_and(|target| self.link_url(&target).is_some())
170    }
171
172    fn focused_link_target(&self) -> Option<GridTarget<KeyOf<D>, ColumnOf<D>>> {
173        let focus = self.selection.focus()?;
174        Some(GridTarget::Cell {
175            key: focus.row()?.clone(),
176            column: focus.column(),
177        })
178    }
179
180    /// Binds `OpenLink` (Enter on a focused link).
181    pub(super) fn bind_link_actions<E: gpui_kit::InteractiveElement>(
182        root: E,
183        cx: &Context<Self>,
184    ) -> E {
185        root.on_action(cx.listener(Self::on_open_link))
186    }
187
188    /// Enter on a focused link opens it; on any other cell the key goes on
189    /// to `Confirm`.
190    fn on_open_link(&mut self, _: &OpenLink, _: &mut Window, cx: &mut Context<Self>) {
191        let opened = self
192            .focused_link_target()
193            .is_some_and(|target| self.open_link_of(&target, cx));
194        if !opened {
195            cx.propagate();
196        }
197    }
198}