Skip to main content

oino_ui/widgets/
block.rs

1//! ratatui `Block` (§4.5.1): [`Block`] node, [`BlockProps`] (the common `block`
2//! prop of every widget), borders, padding and titles.
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
5
6use crate::node::{ActionId, Common, IntoNode, Node};
7use crate::text::{Alignment, Line};
8use crate::{style::Color, style::Style};
9
10/// ratatui `BorderType`.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum BorderType {
13    /// Single line.
14    #[default]
15    Plain,
16    /// Rounded corners.
17    Rounded,
18    /// Double line.
19    Double,
20    /// Thick line.
21    Thick,
22}
23
24/// One border side.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum BorderSide {
27    /// Top.
28    TOP,
29    /// Right.
30    RIGHT,
31    /// Bottom.
32    BOTTOM,
33    /// Left.
34    LEFT,
35}
36
37/// Which borders to draw: `"ALL"`, `"NONE"` or a list of sides (§4.5.1).
38/// Unknown strings parse tolerantly as [`Borders::Other`] (dropped with a diagnostic
39/// at render time).
40#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
41pub enum Borders {
42    /// All sides (`Block::bordered()`).
43    All,
44    /// No sides (the default).
45    #[default]
46    None_,
47    /// Explicit side list.
48    Sides(Vec<BorderSide>),
49    /// Unknown string: renderers drop it instead of rejecting the tree (§4.8).
50    Other(String),
51}
52
53/// Marker constants for the [`Borders`] shorthand strings.
54pub mod borders {
55    /// `"ALL"` shorthand.
56    pub const ALL: &str = "ALL";
57    /// `"NONE"` shorthand.
58    pub const NONE: &str = "NONE";
59}
60
61impl Serialize for Borders {
62    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
63        match self {
64            Self::All => serializer.serialize_str(borders::ALL),
65            Self::None_ => serializer.serialize_str(borders::NONE),
66            Self::Sides(sides) => sides.serialize(serializer),
67            Self::Other(name) => serializer.serialize_str(name),
68        }
69    }
70}
71
72impl<'de> Deserialize<'de> for Borders {
73    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
74        let raw = serde_json::Value::deserialize(deserializer)?;
75        match raw {
76            serde_json::Value::String(name) => match name.as_str() {
77                borders::ALL => Ok(Self::All),
78                borders::NONE => Ok(Self::None_),
79                _ => Ok(Self::Other(name)),
80            },
81            serde_json::Value::Array(_) => {
82                let sides = Vec::<BorderSide>::deserialize(raw).map_err(D::Error::custom)?;
83                Ok(Self::Sides(sides))
84            }
85            _ => Err(D::Error::custom(
86                "borders must be \"ALL\", \"NONE\" or an array of sides",
87            )),
88        }
89    }
90}
91
92/// Title position.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
94pub enum TitlePosition {
95    /// Top edge.
96    #[default]
97    Top,
98    /// Bottom edge.
99    Bottom,
100}
101
102/// One positioned block title.
103#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct Title {
106    /// Title line.
107    pub content: Line,
108    /// Title alignment.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub alignment: Option<Alignment>,
111    /// Edge the title sits on.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub position: Option<TitlePosition>,
114}
115
116impl Title {
117    /// Title on the top edge.
118    #[must_use]
119    pub fn top(content: impl Into<Line>) -> Self {
120        Self {
121            content: content.into(),
122            alignment: None,
123            position: Some(TitlePosition::Top),
124        }
125    }
126
127    /// Title on the bottom edge.
128    #[must_use]
129    pub fn bottom(content: impl Into<Line>) -> Self {
130        Self {
131            content: content.into(),
132            alignment: None,
133            position: Some(TitlePosition::Bottom),
134        }
135    }
136
137    /// Sets the alignment.
138    #[must_use]
139    pub fn alignment(mut self, alignment: Alignment) -> Self {
140        self.alignment = Some(alignment);
141        self
142    }
143}
144
145/// Block `title` prop: a single [`Line`] or a list of positioned [`Title`]s.
146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
147#[serde(untagged)]
148pub enum BlockTitle {
149    /// Single top title.
150    Line(Line),
151    /// Positioned titles.
152    Titles(Vec<Title>),
153}
154
155impl From<&str> for BlockTitle {
156    fn from(s: &str) -> Self {
157        Self::Line(Line::from(s))
158    }
159}
160
161impl From<String> for BlockTitle {
162    fn from(s: String) -> Self {
163        Self::Line(Line::from(s))
164    }
165}
166
167impl From<Line> for BlockTitle {
168    fn from(line: Line) -> Self {
169        Self::Line(line)
170    }
171}
172
173impl From<Vec<Title>> for BlockTitle {
174    fn from(titles: Vec<Title>) -> Self {
175        Self::Titles(titles)
176    }
177}
178
179/// Block padding: a number (all sides) or `{left, right, top, bottom}`.
180#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
181#[serde(untagged)]
182pub enum Padding {
183    /// Same padding on all sides.
184    Uniform(u16),
185    /// Per-side padding.
186    Rect {
187        /// Left padding.
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        left: Option<u16>,
190        /// Right padding.
191        #[serde(default, skip_serializing_if = "Option::is_none")]
192        right: Option<u16>,
193        /// Top padding.
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        top: Option<u16>,
196        /// Bottom padding.
197        #[serde(default, skip_serializing_if = "Option::is_none")]
198        bottom: Option<u16>,
199    },
200}
201
202impl Padding {
203    /// Same padding on all sides.
204    #[must_use]
205    pub fn uniform(n: u16) -> Self {
206        Self::Uniform(n)
207    }
208
209    /// Per-side padding.
210    #[must_use]
211    pub fn new(left: u16, right: u16, top: u16, bottom: u16) -> Self {
212        Self::Rect {
213            left: Some(left),
214            right: Some(right),
215            top: Some(top),
216            bottom: Some(bottom),
217        }
218    }
219
220    /// Horizontal (left / right) padding.
221    #[must_use]
222    pub fn horizontal(n: u16) -> Self {
223        Self::Rect {
224            left: Some(n),
225            right: Some(n),
226            top: None,
227            bottom: None,
228        }
229    }
230
231    /// Vertical (top / bottom) padding.
232    #[must_use]
233    pub fn vertical(n: u16) -> Self {
234        Self::Rect {
235            left: None,
236            right: None,
237            top: Some(n),
238            bottom: Some(n),
239        }
240    }
241}
242
243/// The `block` prop common to every widget that ratatui allows a block on
244/// (Appendix A `BlockProps`; same props as the [`Block`] node minus `child`).
245#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct BlockProps {
248    /// One top title, or positioned titles.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub title: Option<BlockTitle>,
251    /// Default title alignment.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub title_alignment: Option<Alignment>,
254    /// Which borders to draw (default `"NONE"`).
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub borders: Option<Borders>,
257    /// Border glyph set.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub border_type: Option<BorderType>,
260    /// Border style.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub border_style: Option<Style>,
263    /// Title style.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub title_style: Option<Style>,
266    /// Inner padding.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub padding: Option<Padding>,
269    /// GUI extra: show a disclosure toggle in the title.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub collapsible: Option<bool>,
272    /// GUI extra: start collapsed.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub collapsed: Option<bool>,
275    /// GUI extra: left rail in that colour.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub rail: Option<Color>,
278    /// Base style of the block's area.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub style: Option<Style>,
281}
282
283impl BlockProps {
284    /// Block with all borders (`Block::bordered()`).
285    #[must_use]
286    pub fn bordered() -> Self {
287        Self {
288            borders: Some(Borders::All),
289            ..Self::default()
290        }
291    }
292
293    /// Sets the (top) title.
294    #[must_use]
295    pub fn title(mut self, title: impl Into<BlockTitle>) -> Self {
296        self.title = Some(title.into());
297        self
298    }
299
300    /// Sets the default title alignment.
301    #[must_use]
302    pub fn title_alignment(mut self, alignment: Alignment) -> Self {
303        self.title_alignment = Some(alignment);
304        self
305    }
306
307    /// Sets which borders to draw.
308    #[must_use]
309    pub fn borders(mut self, borders: Borders) -> Self {
310        self.borders = Some(borders);
311        self
312    }
313
314    /// Sets the border glyph set.
315    #[must_use]
316    pub fn border_type(mut self, border_type: BorderType) -> Self {
317        self.border_type = Some(border_type);
318        self
319    }
320
321    /// Sets the border style.
322    #[must_use]
323    pub fn border_style(mut self, style: Style) -> Self {
324        self.border_style = Some(style);
325        self
326    }
327
328    /// Sets the title style.
329    #[must_use]
330    pub fn title_style(mut self, style: Style) -> Self {
331        self.title_style = Some(style);
332        self
333    }
334
335    /// Sets the inner padding.
336    #[must_use]
337    pub fn padding(mut self, padding: Padding) -> Self {
338        self.padding = Some(padding);
339        self
340    }
341
342    /// GUI extra: show a disclosure toggle in the title.
343    #[must_use]
344    pub fn collapsible(mut self, collapsible: bool) -> Self {
345        self.collapsible = Some(collapsible);
346        self
347    }
348
349    /// GUI extra: start collapsed.
350    #[must_use]
351    pub fn collapsed(mut self, collapsed: bool) -> Self {
352        self.collapsed = Some(collapsed);
353        self
354    }
355
356    /// GUI extra: left rail in that colour.
357    #[must_use]
358    pub fn rail(mut self, color: Color) -> Self {
359        self.rail = Some(color);
360        self
361    }
362
363    /// Sets the base style of the block's area.
364    #[must_use]
365    pub fn style(mut self, style: Style) -> Self {
366        self.style = Some(style);
367        self
368    }
369}
370
371/// ratatui `Block` node: draws chrome around an optional child (§4.5.1).
372#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct Block {
375    /// Common node props (`key`, `style`, `block`, `fallback`).
376    #[serde(flatten)]
377    pub common: Common,
378    /// One top title, or positioned titles.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub title: Option<BlockTitle>,
381    /// Default title alignment.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub title_alignment: Option<Alignment>,
384    /// Which borders to draw (default `"NONE"`).
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub borders: Option<Borders>,
387    /// Border glyph set.
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub border_type: Option<BorderType>,
390    /// Border style.
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub border_style: Option<Style>,
393    /// Title style.
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub title_style: Option<Style>,
396    /// Inner padding.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub padding: Option<Padding>,
399    /// GUI extra: show a disclosure toggle in the title (`onToggle` action → `bool`).
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub collapsible: Option<bool>,
402    /// GUI extra: start collapsed.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub collapsed: Option<bool>,
405    /// GUI extra: left rail in that colour.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub rail: Option<Color>,
408    /// The widget rendered inside the block's inner area.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub child: Option<Box<Node>>,
411    /// Action fired when the disclosure toggle flips.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub on_toggle: Option<ActionId>,
414}
415
416impl Block {
417    /// Block with all borders.
418    #[must_use]
419    pub fn bordered() -> Self {
420        Self {
421            borders: Some(Borders::All),
422            ..Self::default()
423        }
424    }
425
426    /// Sets the (top) title.
427    #[must_use]
428    pub fn title(mut self, title: impl Into<BlockTitle>) -> Self {
429        self.title = Some(title.into());
430        self
431    }
432
433    /// Sets the default title alignment.
434    #[must_use]
435    pub fn title_alignment(mut self, alignment: Alignment) -> Self {
436        self.title_alignment = Some(alignment);
437        self
438    }
439
440    /// Sets which borders to draw.
441    #[must_use]
442    pub fn borders(mut self, borders: Borders) -> Self {
443        self.borders = Some(borders);
444        self
445    }
446
447    /// Sets the border glyph set.
448    #[must_use]
449    pub fn border_type(mut self, border_type: BorderType) -> Self {
450        self.border_type = Some(border_type);
451        self
452    }
453
454    /// Sets the border style.
455    #[must_use]
456    pub fn border_style(mut self, style: Style) -> Self {
457        self.border_style = Some(style);
458        self
459    }
460
461    /// Sets the title style.
462    #[must_use]
463    pub fn title_style(mut self, style: Style) -> Self {
464        self.title_style = Some(style);
465        self
466    }
467
468    /// Sets the inner padding.
469    #[must_use]
470    pub fn padding(mut self, padding: Padding) -> Self {
471        self.padding = Some(padding);
472        self
473    }
474
475    /// GUI extra: show a disclosure toggle in the title.
476    #[must_use]
477    pub fn collapsible(mut self, collapsible: bool) -> Self {
478        self.collapsible = Some(collapsible);
479        self
480    }
481
482    /// GUI extra: start collapsed.
483    #[must_use]
484    pub fn collapsed(mut self, collapsed: bool) -> Self {
485        self.collapsed = Some(collapsed);
486        self
487    }
488
489    /// GUI extra: left rail in that colour.
490    #[must_use]
491    pub fn rail(mut self, color: Color) -> Self {
492        self.rail = Some(color);
493        self
494    }
495
496    /// Sets the toggle action.
497    #[must_use]
498    pub fn on_toggle(mut self, action: impl Into<ActionId>) -> Self {
499        self.on_toggle = Some(action.into());
500        self
501    }
502
503    /// Sets the child widget.
504    #[must_use]
505    pub fn child(mut self, child: impl IntoNode) -> Self {
506        self.child = Some(Box::new(child.into_node()));
507        self
508    }
509}
510
511impl From<Block> for BlockProps {
512    fn from(block: Block) -> Self {
513        Self {
514            title: block.title,
515            title_alignment: block.title_alignment,
516            borders: block.borders,
517            border_type: block.border_type,
518            border_style: block.border_style,
519            title_style: block.title_style,
520            padding: block.padding,
521            collapsible: block.collapsible,
522            collapsed: block.collapsed,
523            rail: block.rail,
524            style: block.common.style,
525        }
526    }
527}
528
529impl IntoNode for Block {}
530impl From<Block> for Node {
531    fn from(b: Block) -> Self {
532        Node::Block(b)
533    }
534}
535#[cfg(test)]
536mod tests {
537    #![allow(
538        clippy::unwrap_used,
539        clippy::expect_used,
540        clippy::panic,
541        reason = "tests assert by unwrapping"
542    )]
543    use super::*;
544
545    #[test]
546    fn borders_shorthand() {
547        let b: Borders = serde_json::from_str("\"ALL\"").unwrap_or_default();
548        assert_eq!(b, Borders::All);
549        let b: Borders = serde_json::from_str("[\"TOP\",\"LEFT\"]").unwrap_or_default();
550        assert_eq!(b, Borders::Sides(vec![BorderSide::TOP, BorderSide::LEFT]));
551    }
552}