aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-utils/src/input/theme.rs
blob: 4a33db5effe14937b939f8b2180f04b3f6fb66a2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
use serde::{
    de::{Error, Visitor},
    Deserialize, Deserializer, Serialize, Serializer,
};
use std::{
    collections::{BTreeMap, HashMap},
    fmt,
};

use crate::data::Styling;

/// How a status bar action button is decorated (OSC 5577).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum StatusBarButtonStyle {
    /// `[Approve]` -- bracketed, no index.
    Boxed,
    /// `1:Approve` -- the button's 1-based position, then its label. Useful when
    /// something else binds those numbers to the same actions, so the bar shows
    /// which key does what.
    Indexed,
}

impl Default for StatusBarButtonStyle {
    fn default() -> Self {
        StatusBarButtonStyle::Boxed
    }
}

impl StatusBarButtonStyle {
    pub fn from_str(name: &str) -> Option<Self> {
        match name.trim().to_lowercase().as_str() {
            "boxed" => Some(StatusBarButtonStyle::Boxed),
            "indexed" => Some(StatusBarButtonStyle::Indexed),
            _ => None,
        }
    }
    pub fn possible_values() -> &'static str {
        "\"boxed\", \"indexed\""
    }
    /// How this style renders a button. `index` is 1-based.
    pub fn render(&self, index: usize, label: &str) -> String {
        match self {
            StatusBarButtonStyle::Boxed => format!("[{}]", label),
            StatusBarButtonStyle::Indexed => format!("{}:{}", index, label),
        }
    }
}

/// Visual treatment of the stacked-pane picker's entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum StackListStyle {
    /// Each entry bracketed in vertical bars, with a "> " marker on the active
    /// one.
    Boxed,
    /// No brackets and no marker; the active entry is shown by inverting its
    /// foreground and background instead.
    Minimal,
}

impl Default for StackListStyle {
    fn default() -> Self {
        StackListStyle::Boxed
    }
}

impl StackListStyle {
    pub fn from_str(name: &str) -> Option<Self> {
        match name.trim().to_lowercase().as_str() {
            "boxed" => Some(StackListStyle::Boxed),
            "minimal" => Some(StackListStyle::Minimal),
            _ => None,
        }
    }
    pub fn possible_values() -> &'static str {
        "\"boxed\", \"minimal\""
    }
    /// Columns an entry spends on decoration. The picker's column arithmetic
    /// lives in Tab and must agree with the renderer, so both read this.
    pub fn decoration_cols(&self) -> usize {
        match self {
            // "> " marker, plus a border and a pad on each side
            StackListStyle::Boxed => 6,
            // one pad each side, so the inverted block has some air around it
            StackListStyle::Minimal => 2,
        }
    }
}

#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
pub struct UiConfig {
    pub pane_frames: FrameConfig,
}

impl UiConfig {
    pub fn merge(&self, other: UiConfig) -> Self {
        let mut merged = self.clone();
        merged.pane_frames = merged.pane_frames.merge(other.pane_frames);
        merged
    }
}

#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct FrameConfig {
    pub rounded_corners: bool,
    pub hide_session_name: bool,
    pub title_alignment: crate::data::PaneTitleAlignment,
    /// outer gap between panes, expressed in ROWS (see Style::pane_gap_y).
    /// Columns are scaled by `gap_aspect` so the gap looks square.
    pub gap: usize,
    /// inner padding between a pane's frame and its content, per side.
    /// Already resolved at parse time (including any aspect correction).
    pub padding: crate::data::PanePadding,
    /// Columns per row. Terminal cells are roughly twice as tall as they are
    /// wide, so 1 row of gap needs ~2 columns to look like the same distance.
    /// 0 is treated as 1 (no correction).
    pub gap_aspect: usize,
    /// How strongly to desaturate the CONTENT of unfocused panes, 0-100.
    /// 0 disables it entirely (the default).
    pub inactive_grayscale: u8,
    /// Omit the auto-generated "Pane #N" placeholder title, so a pane shows no
    /// name until the user sets one or the running program sets a title.
    pub hide_default_pane_names: bool,
    /// Collapse to the swap layout named `responsive_layout` when the terminal
    /// is narrower than this many columns. 0 disables the whole feature.
    pub responsive_min_cols: usize,
    /// Name of the swap tiled layout to use when below the threshold.
    pub responsive_layout: String,
    /// Cap on how many entries the stacked-pane picker shows at once. The list
    /// windows around the focused entry and reports the rest as "+N". 0 = show
    /// every member, however many rows that takes.
    pub stack_list_max_rows: usize,
    /// Pack picker entries into a grid instead of one per row. The row cap then
    /// becomes a space budget rather than a count.
    pub stack_list_grid: bool,
    /// Visual treatment of picker entries.
    pub stack_list_style: StackListStyle,
    /// How status bar action buttons are decorated.
    pub status_bar_button_style: StatusBarButtonStyle,
}

/// Hand-written rather than derived: `gap_aspect` must default to 2 to match
/// the KDL parser's default. A derived Default would give 0, which makes a
/// parsed config disagree with a default-constructed one and shows up as
/// config round-trip diffs.
impl Default for FrameConfig {
    fn default() -> Self {
        FrameConfig {
            rounded_corners: false,
            hide_session_name: false,
            title_alignment: Default::default(),
            gap: 0,
            padding: Default::default(),
            gap_aspect: 2,
            inactive_grayscale: 0,
            hide_default_pane_names: false,
            responsive_min_cols: 0,
            responsive_layout: String::from("stacked"),
            stack_list_max_rows: 0,
            stack_list_grid: false,
            stack_list_style: StackListStyle::Boxed,
            status_bar_button_style: StatusBarButtonStyle::Boxed,
        }
    }
}

impl FrameConfig {
    fn aspect(&self) -> usize {
        self.gap_aspect.max(1)
    }
    /// (columns, rows) of outer gap
    pub fn resolved_gap(&self) -> (usize, usize) {
        (self.gap * self.aspect(), self.gap)
    }
    pub fn resolved_padding(&self) -> crate::data::PanePadding {
        self.padding
    }
}

impl FrameConfig {
    pub fn merge(&self, other: FrameConfig) -> Self {
        let mut merged = self.clone();
        merged.rounded_corners = other.rounded_corners;
        merged.hide_session_name = other.hide_session_name;
        merged.title_alignment = other.title_alignment;
        merged.gap = other.gap;
        merged.padding = other.padding;
        merged.gap_aspect = other.gap_aspect;
        merged.inactive_grayscale = other.inactive_grayscale;
        merged.hide_default_pane_names = other.hide_default_pane_names;
        merged.responsive_min_cols = other.responsive_min_cols;
        merged.responsive_layout = other.responsive_layout.clone();
        merged.stack_list_max_rows = other.stack_list_max_rows;
        merged.stack_list_grid = other.stack_list_grid;
        merged.stack_list_style = other.stack_list_style;
        merged.status_bar_button_style = other.status_bar_button_style;
        merged
    }
}

#[derive(Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Themes(HashMap<String, Theme>);

impl fmt::Debug for Themes {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let mut stable_sorted = BTreeMap::new();
        for (theme_name, theme) in self.0.iter() {
            stable_sorted.insert(theme_name, theme);
        }
        write!(f, "{:#?}", stable_sorted)
    }
}

impl Themes {
    pub fn from_data(theme_data: HashMap<String, Theme>) -> Self {
        Themes(theme_data)
    }
    pub fn insert(&mut self, theme_name: String, theme: Theme) {
        self.0.insert(theme_name, theme);
    }
    pub fn merge(&self, mut other: Themes) -> Self {
        let mut merged = self.clone();
        for (name, theme) in other.0.drain() {
            merged.0.insert(name, theme);
        }
        merged
    }
    pub fn get_theme(&self, theme_name: &str) -> Option<&Theme> {
        self.0.get(theme_name)
    }
    pub fn inner(&self) -> &HashMap<String, Theme> {
        &self.0
    }
}

#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Theme {
    pub sourced_from_external_file: bool,
    #[serde(flatten)]
    pub palette: Styling,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct HexColor(u8, u8, u8);

impl From<HexColor> for (u8, u8, u8) {
    fn from(e: HexColor) -> (u8, u8, u8) {
        let HexColor(r, g, b) = e;
        (r, g, b)
    }
}

pub struct HexColorVisitor();

impl<'de> Visitor<'de> for HexColorVisitor {
    type Value = HexColor;

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        write!(formatter, "a hex color in the format #RGB or #RRGGBB")
    }

    fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
    where
        E: Error,
    {
        if let Some(stripped) = s.strip_prefix('#') {
            return self.visit_str(stripped);
        }

        if s.len() == 3 {
            Ok(HexColor(
                u8::from_str_radix(&s[0..1], 16).map_err(E::custom)? * 0x11,
                u8::from_str_radix(&s[1..2], 16).map_err(E::custom)? * 0x11,
                u8::from_str_radix(&s[2..3], 16).map_err(E::custom)? * 0x11,
            ))
        } else if s.len() == 6 {
            Ok(HexColor(
                u8::from_str_radix(&s[0..2], 16).map_err(E::custom)?,
                u8::from_str_radix(&s[2..4], 16).map_err(E::custom)?,
                u8::from_str_radix(&s[4..6], 16).map_err(E::custom)?,
            ))
        } else {
            Err(Error::custom(
                "Hex color must be of form \"#RGB\" or \"#RRGGBB\"",
            ))
        }
    }
}

impl<'de> Deserialize<'de> for HexColor {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_str(HexColorVisitor())
    }
}
impl Serialize for HexColor {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2).as_str())
    }
}

#[cfg(test)]
#[path = "./unit/theme_test.rs"]
mod theme_test;