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}