Skip to main content

hjkl_info_popup/
lib.rs

1//! Renderer-agnostic info popup data model.
2//!
3//! Provides [`InfoPopup`] — the state for a centered floating overlay used by
4//! `:reg`, `:marks`, `:jumps`, `:changes`, and the K-key LSP hover info path.
5//! No rendering types are referenced; the TUI adapter lives in
6//! `hjkl-info-popup-tui`.
7//!
8//! # Quick start
9//!
10//! ```rust
11//! use hjkl_info_popup::{InfoPopup, InfoPosition, ContentKind};
12//!
13//! let popup = InfoPopup::new("registers", "\"a  hello\n\"b  world");
14//! assert_eq!(popup.title, " registers ");
15//! assert!(!popup.dismissed);
16//! assert_eq!(popup.lines().count(), 2);
17//! ```
18
19// ── Public types ──────────────────────────────────────────────────────────────
20
21/// How the popup content should be interpreted by the renderer.
22///
23/// `#[non_exhaustive]` — new variants may be added in minor releases.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25#[non_exhaustive]
26pub enum ContentKind {
27    /// Plain text — rendered as-is with no markdown interpretation.
28    #[default]
29    Plain,
30    /// CommonMark markdown — the TUI adapter uses `hjkl-markdown-tui` to
31    /// parse and render the content with syntax highlighting.
32    Markdown,
33}
34
35/// How the popup is positioned within the available area.
36///
37/// `#[non_exhaustive]` — new variants may be added in minor releases.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39#[non_exhaustive]
40pub enum InfoPosition {
41    /// Centered horizontally and vertically.  80% wide, 60% tall.
42    #[default]
43    Centered,
44}
45
46/// All state needed to display a centered info popup overlay.
47///
48/// The popup shows multi-line content from `:reg`, `:marks`, `:jumps`,
49/// `:changes`, or the K-key LSP hover path.  Any keypress dismisses it (the
50/// event loop sets `dismissed = true` or drops the value).
51///
52/// `#[non_exhaustive]` — new fields may be added in minor releases.
53#[derive(Debug, Clone)]
54#[non_exhaustive]
55pub struct InfoPopup {
56    /// Popup title, including surrounding spaces (e.g. `" info "`).
57    pub title: String,
58    /// Full multi-line content string.  Lines are separated by `'\n'`.
59    pub content: String,
60    /// How `content` should be interpreted by the renderer.
61    pub kind: ContentKind,
62    /// Positioning strategy.
63    pub position: InfoPosition,
64    /// Whether the popup has been explicitly dismissed.
65    pub dismissed: bool,
66}
67
68impl InfoPopup {
69    /// Create a plain-text popup with the given `title` and `content`.
70    ///
71    /// The title is wrapped with a leading and trailing space so ratatui renders
72    /// it with padding (e.g. `" registers "`).
73    ///
74    /// ```rust
75    /// use hjkl_info_popup::InfoPopup;
76    ///
77    /// let p = InfoPopup::new("marks", "  '  1  some/file.rs\n  \"  2  other.rs");
78    /// assert_eq!(p.title, " marks ");
79    /// assert!(!p.dismissed);
80    /// ```
81    pub fn new(title: &str, content: impl Into<String>) -> Self {
82        Self {
83            title: format!(" {title} "),
84            content: content.into(),
85            kind: ContentKind::Plain,
86            position: InfoPosition::Centered,
87            dismissed: false,
88        }
89    }
90
91    /// Create a markdown popup with the given `title` and `content`.
92    ///
93    /// Used by the K-key LSP hover path; the TUI adapter parses the content
94    /// with `hjkl-markdown-tui` for syntax-aware rendering.
95    ///
96    /// ```rust
97    /// use hjkl_info_popup::{InfoPopup, ContentKind};
98    ///
99    /// let p = InfoPopup::markdown("hover", "# Fn\n\nDoes a thing.");
100    /// assert_eq!(p.kind, ContentKind::Markdown);
101    /// ```
102    pub fn markdown(title: &str, content: impl Into<String>) -> Self {
103        Self {
104            title: format!(" {title} "),
105            content: content.into(),
106            kind: ContentKind::Markdown,
107            position: InfoPosition::Centered,
108            dismissed: false,
109        }
110    }
111
112    /// Iterator over the content lines.
113    ///
114    /// ```rust
115    /// use hjkl_info_popup::InfoPopup;
116    ///
117    /// let p = InfoPopup::new("info", "line1\nline2\nline3");
118    /// assert_eq!(p.lines().count(), 3);
119    /// ```
120    pub fn lines(&self) -> impl Iterator<Item = &str> {
121        self.content.lines()
122    }
123
124    /// Number of content lines.
125    pub fn line_count(&self) -> usize {
126        self.content.lines().count().max(1)
127    }
128}
129
130impl Default for InfoPopup {
131    fn default() -> Self {
132        Self::new("info", String::new())
133    }
134}
135
136impl From<String> for InfoPopup {
137    /// Convert a raw plain-text string into an `InfoPopup` with the default
138    /// `" info "` title.  Convenient for call sites that used to hold
139    /// `Option<String>`.
140    fn from(content: String) -> Self {
141        Self::new("info", content)
142    }
143}
144
145// ── Geometry helpers ──────────────────────────────────────────────────────────
146
147/// Viewport dimensions for popup placement.
148///
149/// `#[non_exhaustive]` — new fields may be added in minor releases.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct InfoViewport {
153    /// Total width of the area the popup may occupy.
154    pub width: u16,
155    /// Total height of the area the popup may occupy.
156    pub height: u16,
157}
158
159impl InfoViewport {
160    /// Convenience constructor.
161    pub fn new(width: u16, height: u16) -> Self {
162        Self { width, height }
163    }
164}
165
166/// Bounding rect returned by [`geometry`].
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168#[non_exhaustive]
169pub struct InfoRect {
170    /// Left column (0-based).
171    pub x: u16,
172    /// Top row (0-based).
173    pub y: u16,
174    /// Width in columns.
175    pub width: u16,
176    /// Height in rows.
177    pub height: u16,
178}
179
180/// Compute the bounding rect for an [`InfoPopup`] within `viewport`.
181///
182/// For [`InfoPosition::Centered`], the popup occupies 80% of the viewport width
183/// and 60% of the height, clamped to at least 4×3.
184///
185/// ```rust
186/// use hjkl_info_popup::{InfoPopup, InfoViewport, geometry};
187///
188/// let popup = InfoPopup::new("reg", "\"a  hello");
189/// let r = geometry(&popup, InfoViewport::new(80, 24));
190/// assert!(r.x + r.width <= 80);
191/// assert!(r.y + r.height <= 24);
192/// ```
193pub fn geometry(popup: &InfoPopup, viewport: InfoViewport) -> InfoRect {
194    match popup.position {
195        InfoPosition::Centered => centered_rect(80, 60, viewport),
196        // Future positions handled here.
197    }
198}
199
200fn centered_rect(pct_x: u16, pct_y: u16, vp: InfoViewport) -> InfoRect {
201    let width = (vp.width.saturating_mul(pct_x) / 100).max(4).min(vp.width);
202    let height = (vp.height.saturating_mul(pct_y) / 100)
203        .max(3)
204        .min(vp.height);
205    let x = (vp.width.saturating_sub(width)) / 2;
206    let y = (vp.height.saturating_sub(height)) / 2;
207    InfoRect {
208        x,
209        y,
210        width,
211        height,
212    }
213}
214
215// ── Tests ─────────────────────────────────────────────────────────────────────
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn new_wraps_title_with_spaces() {
223        let p = InfoPopup::new("registers", "content");
224        assert_eq!(p.title, " registers ");
225    }
226
227    #[test]
228    fn dismissed_defaults_to_false() {
229        let p = InfoPopup::new("marks", "");
230        assert!(!p.dismissed);
231    }
232
233    #[test]
234    fn lines_count() {
235        let p = InfoPopup::new("jumps", "a\nb\nc");
236        assert_eq!(p.lines().count(), 3);
237    }
238
239    #[test]
240    fn line_count_minimum_one_for_empty() {
241        let p = InfoPopup::new("changes", "");
242        assert_eq!(p.line_count(), 1);
243    }
244
245    #[test]
246    fn default_is_info_title_empty_content() {
247        let p = InfoPopup::default();
248        assert_eq!(p.title, " info ");
249        assert!(p.content.is_empty());
250    }
251
252    #[test]
253    fn new_defaults_to_plain_content_kind() {
254        let p = InfoPopup::new("reg", "\"a  hello");
255        assert_eq!(p.kind, ContentKind::Plain);
256    }
257
258    #[test]
259    fn markdown_constructor_sets_markdown_kind() {
260        let p = InfoPopup::markdown("hover", "# Title\n\nhello");
261        assert_eq!(p.kind, ContentKind::Markdown);
262    }
263
264    #[test]
265    fn from_string_gives_plain_popup() {
266        let p = InfoPopup::from("some text".to_string());
267        assert_eq!(p.kind, ContentKind::Plain);
268        assert_eq!(p.content, "some text");
269    }
270
271    #[test]
272    fn geometry_centered_stays_inside_viewport() {
273        let p = InfoPopup::new("reg", "hello");
274        let r = geometry(&p, InfoViewport::new(80, 24));
275        assert!(r.x + r.width <= 80, "overflow right");
276        assert!(r.y + r.height <= 24, "overflow bottom");
277    }
278
279    #[test]
280    fn geometry_centered_80_60_pct() {
281        let p = InfoPopup::new("reg", "hello");
282        let vp = InfoViewport::new(100, 40);
283        let r = geometry(&p, vp);
284        // 80% of 100 = 80 width, 60% of 40 = 24 height
285        assert_eq!(r.width, 80);
286        assert_eq!(r.height, 24);
287        // centered: x = (100-80)/2 = 10
288        assert_eq!(r.x, 10);
289        assert_eq!(r.y, 8); // (40-24)/2
290    }
291
292    #[test]
293    fn geometry_clamps_to_minimum() {
294        let p = InfoPopup::new("reg", "x");
295        let r = geometry(&p, InfoViewport::new(3, 2));
296        assert!(r.width >= 4 || r.width == 3); // clamped to viewport
297        assert!(r.height >= 3 || r.height == 2);
298    }
299}