• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdash-rs / kdash / 24324833773

13 Apr 2026 03:52AM UTC coverage: 71.599% (-0.1%) from 71.732%
24324833773

Pull #515

github

web-flow
Merge bc5ffebe2 into 5eb37a632
Pull Request #515: feat(ui): more efficient redraw

79 of 164 new or added lines in 3 files covered. (48.17%)

4 existing lines in 2 files now uncovered.

10011 of 13982 relevant lines covered (71.6%)

147.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.83
/src/ui/utils.rs
1
use std::{borrow::Cow, collections::BTreeMap, io::Cursor, rc::Rc, sync::OnceLock};
2

3
use glob_match::glob_match;
4
use ratatui::{
5
  layout::{Constraint, Direction, Layout, Position, Rect},
6
  style::{Color, Modifier, Style},
7
  symbols,
8
  text::{Line, Span, Text},
9
  widgets::{Block, Borders, Paragraph, Row, Table, Wrap},
10
  Frame,
11
};
12
use serde::Serialize;
13

14
use super::HIGHLIGHT;
15
use crate::app::{
16
  key_binding::DEFAULT_KEYBINDING,
17
  models::{KubeResource, StatefulTable},
18
  ActiveBlock, App,
19
};
20
use crate::event::Key;
21
use crate::ui::theme::override_color;
22
// Utils
23

24
#[derive(Clone, Debug, PartialEq, Eq)]
25
pub enum LinePart<'a> {
26
  Default(Cow<'a, str>),
27
  Help(Cow<'a, str>),
28
}
29

30
// Catppuccin Macchiato (dark)
31
pub const MACCHIATO_BASE: Color = Color::Rgb(36, 39, 58);
32
pub const MACCHIATO_BLUE: Color = Color::Rgb(138, 173, 244);
33
pub const MACCHIATO_GREEN: Color = Color::Rgb(166, 218, 149);
34
pub const MACCHIATO_RED: Color = Color::Rgb(237, 135, 150);
35
pub const MACCHIATO_YELLOW: Color = Color::Rgb(238, 212, 159);
36
pub const MACCHIATO_PEACH: Color = Color::Rgb(245, 169, 127);
37
pub const MACCHIATO_TEXT: Color = Color::Rgb(202, 211, 245);
38
pub const MACCHIATO_MAUVE: Color = Color::Rgb(198, 160, 246);
39
// Catppuccin Latte (light)
40
pub const LATTE_MAUVE: Color = Color::Rgb(136, 57, 239);
41
pub const LATTE_TEXT: Color = Color::Rgb(76, 79, 105);
42
pub const LATTE_BLUE: Color = Color::Rgb(30, 102, 245);
43
pub const LATTE_MAROON: Color = Color::Rgb(230, 69, 83);
44
pub const LATTE_GREEN: Color = Color::Rgb(64, 160, 43);
45
pub const LATTE_RED: Color = Color::Rgb(210, 15, 57);
46
pub const LATTE_PEACH: Color = Color::Rgb(254, 100, 11);
47
pub const LATTE_BASE: Color = Color::Rgb(239, 241, 245);
48
const CATPPUCCIN_MACCHIATO_THEME: &[u8] =
49
  include_bytes!("../../assets/themes/CatppuccinMacchiato.tmTheme");
50
const CATPPUCCIN_LATTE_THEME: &[u8] = include_bytes!("../../assets/themes/CatppuccinLatte.tmTheme");
51

52
/// Convert a syntect highlight segment into an owned ratatui Span.
53
fn syntect_to_ratatui_span_owned(
×
54
  (style, content): (syntect::highlighting::Style, &str),
×
55
) -> Option<Span<'static>> {
×
56
  use syntect::highlighting::FontStyle;
57
  let fg = if style.foreground.a > 0 {
×
58
    Some(Color::Rgb(
×
59
      style.foreground.r,
×
60
      style.foreground.g,
×
61
      style.foreground.b,
×
62
    ))
×
63
  } else {
64
    None
×
65
  };
66
  let bg = if style.background.a > 0 {
×
67
    Some(Color::Rgb(
×
68
      style.background.r,
×
69
      style.background.g,
×
70
      style.background.b,
×
71
    ))
×
72
  } else {
73
    None
×
74
  };
75
  let modifier = {
×
76
    let fs = style.font_style;
×
77
    let mut m = Modifier::empty();
×
78
    if fs.contains(FontStyle::BOLD) {
×
79
      m |= Modifier::BOLD;
×
80
    }
×
81
    if fs.contains(FontStyle::ITALIC) {
×
82
      m |= Modifier::ITALIC;
×
83
    }
×
84
    if fs.contains(FontStyle::UNDERLINE) {
×
85
      m |= Modifier::UNDERLINED;
×
86
    }
×
87
    m
×
88
  };
89
  let ratatui_style = Style::default()
×
90
    .fg(fg.unwrap_or_default())
×
91
    .bg(bg.unwrap_or_default())
×
92
    .add_modifier(modifier);
×
93
  Some(Span::styled(content.to_owned(), ratatui_style))
×
94
}
×
95

96
fn get_syntax_set() -> &'static syntect::parsing::SyntaxSet {
×
97
  static SYNTAX_SET: OnceLock<syntect::parsing::SyntaxSet> = OnceLock::new();
98
  SYNTAX_SET.get_or_init(syntect::parsing::SyntaxSet::load_defaults_newlines)
×
99
}
×
100

101
fn get_yaml_syntax_reference() -> &'static syntect::parsing::SyntaxReference {
×
102
  static YAML_SYNTAX_REFERENCE: OnceLock<syntect::parsing::SyntaxReference> = OnceLock::new();
103
  YAML_SYNTAX_REFERENCE.get_or_init(|| {
×
104
    get_syntax_set()
×
105
      .find_syntax_by_extension("yaml")
×
106
      .unwrap()
×
107
      .clone()
×
108
  })
×
109
}
×
110

111
struct YamlThemes {
112
  dark: syntect::highlighting::Theme,
113
  light: syntect::highlighting::Theme,
114
}
115

116
fn get_yaml_themes() -> &'static YamlThemes {
×
117
  static YAML_THEMES: OnceLock<YamlThemes> = OnceLock::new();
118
  YAML_THEMES.get_or_init(|| {
×
119
    let dark = load_embedded_theme(CATPPUCCIN_MACCHIATO_THEME);
×
120
    let light = load_embedded_theme(CATPPUCCIN_LATTE_THEME);
×
121
    YamlThemes { dark, light }
×
122
  })
×
123
}
×
124

125
fn load_embedded_theme(theme_bytes: &[u8]) -> syntect::highlighting::Theme {
×
126
  syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(theme_bytes))
×
127
    .expect("embedded theme should load")
×
128
}
×
129

130
#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
131
pub enum Styles {
132
  Text,
133
  Failure,
134
  Warning,
135
  Success,
136
  Primary,
137
  Secondary,
138
  Help,
139
  Background,
140
}
141

142
pub fn theme_styles(light: bool) -> BTreeMap<Styles, Style> {
414✔
143
  let mut styles = if light {
414✔
144
    BTreeMap::from([
×
145
      (Styles::Text, Style::default().fg(LATTE_TEXT)),
×
146
      (Styles::Failure, Style::default().fg(LATTE_RED)),
×
147
      (Styles::Warning, Style::default().fg(LATTE_PEACH)),
×
148
      (Styles::Success, Style::default().fg(LATTE_GREEN)),
×
149
      (Styles::Primary, Style::default().fg(LATTE_MAUVE)),
×
150
      (Styles::Secondary, Style::default().fg(LATTE_MAROON)),
×
151
      (Styles::Help, Style::default().fg(LATTE_BLUE)),
×
152
      (
×
153
        Styles::Background,
×
154
        Style::default().bg(LATTE_BASE).fg(LATTE_TEXT),
×
155
      ),
×
156
    ])
×
157
  } else {
158
    BTreeMap::from([
414✔
159
      (Styles::Text, Style::default().fg(MACCHIATO_TEXT)),
414✔
160
      (Styles::Failure, Style::default().fg(MACCHIATO_RED)),
414✔
161
      (Styles::Warning, Style::default().fg(MACCHIATO_PEACH)),
414✔
162
      (Styles::Success, Style::default().fg(MACCHIATO_GREEN)),
414✔
163
      (Styles::Primary, Style::default().fg(MACCHIATO_MAUVE)),
414✔
164
      (Styles::Secondary, Style::default().fg(MACCHIATO_YELLOW)),
414✔
165
      (Styles::Help, Style::default().fg(MACCHIATO_BLUE)),
414✔
166
      (
414✔
167
        Styles::Background,
414✔
168
        Style::default().bg(MACCHIATO_BASE).fg(MACCHIATO_TEXT),
414✔
169
      ),
414✔
170
    ])
414✔
171
  };
172

173
  apply_theme_override(&mut styles, Styles::Text, "text", false, light);
414✔
174
  apply_theme_override(&mut styles, Styles::Failure, "failure", false, light);
414✔
175
  apply_theme_override(&mut styles, Styles::Warning, "warning", false, light);
414✔
176
  apply_theme_override(&mut styles, Styles::Success, "success", false, light);
414✔
177
  apply_theme_override(&mut styles, Styles::Primary, "primary", false, light);
414✔
178
  apply_theme_override(&mut styles, Styles::Secondary, "secondary", false, light);
414✔
179
  apply_theme_override(&mut styles, Styles::Help, "help", false, light);
414✔
180
  apply_theme_override(&mut styles, Styles::Background, "background", true, light);
414✔
181

182
  styles
414✔
183
}
414✔
184

185
pub fn title_style(txt: &str) -> Span<'_> {
8✔
186
  Span::styled(txt, style_bold())
8✔
187
}
8✔
188

189
pub fn default_part<'a, S: Into<Cow<'a, str>>>(text: S) -> LinePart<'a> {
101✔
190
  LinePart::Default(text.into())
101✔
191
}
101✔
192

193
pub fn help_part<'a, S: Into<Cow<'a, str>>>(text: S) -> LinePart<'a> {
156✔
194
  LinePart::Help(text.into())
156✔
195
}
156✔
196

197
pub fn style_header(light: bool) -> Style {
12✔
198
  style_primary(light).add_modifier(Modifier::REVERSED)
12✔
199
}
12✔
200

201
pub fn style_bold() -> Style {
8✔
202
  Style::default().add_modifier(Modifier::BOLD)
8✔
203
}
8✔
204

205
pub fn style_text(light: bool) -> Style {
108✔
206
  *theme_styles(light).get(&Styles::Text).unwrap()
108✔
207
}
108✔
208
pub fn style_logo(light: bool) -> Style {
3✔
209
  style_primary(light)
3✔
210
}
3✔
211
pub fn style_failure(light: bool) -> Style {
10✔
212
  *theme_styles(light).get(&Styles::Failure).unwrap()
10✔
213
}
10✔
214
pub fn style_warning(light: bool) -> Style {
2✔
215
  *theme_styles(light).get(&Styles::Warning).unwrap()
2✔
216
}
2✔
217
pub fn style_caution(light: bool) -> Style {
2✔
218
  style_warning(light)
2✔
219
}
2✔
220
pub fn style_success(light: bool) -> Style {
4✔
221
  *theme_styles(light).get(&Styles::Success).unwrap()
4✔
222
}
4✔
223
pub fn style_primary(light: bool) -> Style {
101✔
224
  *theme_styles(light).get(&Styles::Primary).unwrap()
101✔
225
}
101✔
226
pub fn style_help(light: bool) -> Style {
143✔
227
  *theme_styles(light).get(&Styles::Help).unwrap()
143✔
228
}
143✔
229

230
pub fn style_secondary(light: bool) -> Style {
40✔
231
  *theme_styles(light).get(&Styles::Secondary).unwrap()
40✔
232
}
40✔
233

234
pub fn style_main_background(light: bool) -> Style {
6✔
235
  *theme_styles(light).get(&Styles::Background).unwrap()
6✔
236
}
6✔
237

238
pub fn style_highlight() -> Style {
15✔
239
  Style::default().add_modifier(Modifier::REVERSED)
15✔
240
}
15✔
241

242
fn line_part_style(part: &LinePart<'_>, light: bool, bold: bool) -> Style {
239✔
243
  let style = match part {
239✔
244
    LinePart::Default(_) => style_text(light),
96✔
245
    LinePart::Help(_) => style_help(light),
143✔
246
  };
247
  if bold {
239✔
248
    style.add_modifier(Modifier::BOLD)
97✔
249
  } else {
250
    style
142✔
251
  }
252
}
239✔
253

254
fn apply_theme_override(
3,312✔
255
  styles: &mut BTreeMap<Styles, Style>,
3,312✔
256
  slot: Styles,
3,312✔
257
  config_key: &str,
3,312✔
258
  background: bool,
3,312✔
259
  light: bool,
3,312✔
260
) {
3,312✔
261
  if let Some(color) = override_color(config_key, light) {
3,312✔
262
    let style = styles.entry(slot).or_default();
×
263
    *style = if background {
×
264
      style.bg(color)
×
265
    } else {
266
      style.fg(color)
×
267
    };
268
  }
3,312✔
269
}
3,312✔
270

271
pub fn mixed_line<'a, I>(parts: I, light: bool) -> Line<'a>
74✔
272
where
74✔
273
  I: IntoIterator<Item = LinePart<'a>>,
74✔
274
{
275
  styled_line(parts, light, false)
74✔
276
}
74✔
277

278
pub fn mixed_bold_line<'a, I>(parts: I, light: bool) -> Line<'a>
45✔
279
where
45✔
280
  I: IntoIterator<Item = LinePart<'a>>,
45✔
281
{
282
  styled_line(parts, light, true)
45✔
283
}
45✔
284

285
pub fn help_bold_line<'a, S: Into<Cow<'a, str>>>(text: S, light: bool) -> Line<'a> {
11✔
286
  mixed_bold_line([help_part(text)], light)
11✔
287
}
11✔
288

289
pub fn key_hints(keys: &[Key]) -> String {
9✔
290
  keys
9✔
291
    .iter()
9✔
292
    .map(ToString::to_string)
9✔
293
    .collect::<Vec<_>>()
9✔
294
    .join("/")
9✔
295
}
9✔
296

297
pub fn action_hint(action: &str, key: Key) -> String {
76✔
298
  format!("{} {}", action, key)
76✔
299
}
76✔
300

301
pub fn describe_and_yaml_hint() -> String {
6✔
302
  format!(
6✔
303
    "{} | {} ",
304
    action_hint("describe", DEFAULT_KEYBINDING.describe_resource.key),
6✔
305
    action_hint("yaml", DEFAULT_KEYBINDING.resource_yaml.key)
6✔
306
  )
307
}
6✔
308

309
pub fn describe_yaml_and_logs_hint() -> String {
5✔
310
  format!(
5✔
311
    "{} | {} ",
312
    describe_and_yaml_hint().trim_end(),
5✔
313
    action_hint("logs", DEFAULT_KEYBINDING.aggregate_logs.key)
5✔
314
  )
315
}
5✔
316

317
pub fn describe_yaml_logs_and_esc_hint() -> String {
×
318
  format!(
×
319
    "{} | back {} ",
320
    describe_yaml_and_logs_hint().trim_end(),
×
321
    DEFAULT_KEYBINDING.esc.key
×
322
  )
323
}
×
324

325
pub fn describe_yaml_and_esc_hint() -> String {
×
326
  format!(
×
327
    "{} | back {} ",
328
    describe_and_yaml_hint().trim_end(),
×
329
    DEFAULT_KEYBINDING.esc.key
×
330
  )
331
}
×
332

333
pub fn describe_yaml_decode_and_esc_hint() -> String {
×
334
  format!(
×
335
    "{} | {} | back {} ",
336
    describe_and_yaml_hint().trim_end(),
×
337
    action_hint("decode", DEFAULT_KEYBINDING.decode_secret.key),
×
338
    DEFAULT_KEYBINDING.esc.key
×
339
  )
340
}
×
341

342
pub fn filter_cursor_position(area: Rect, prefix_width: usize, filter: &str) -> Position {
7✔
343
  Position {
7✔
344
    x: area.x
7✔
345
      + (prefix_width as u16 + 1 + filter.chars().count() as u16).min(area.width.saturating_sub(2)),
7✔
346
    y: area.y,
7✔
347
  }
7✔
348
}
7✔
349

350
fn styled_line<'a, I>(parts: I, light: bool, bold: bool) -> Line<'a>
119✔
351
where
119✔
352
  I: IntoIterator<Item = LinePart<'a>>,
119✔
353
{
354
  Line::from(
119✔
355
    parts
119✔
356
      .into_iter()
119✔
357
      .map(|part| {
239✔
358
        let style = line_part_style(&part, light, bold);
239✔
359
        match part {
239✔
360
          LinePart::Default(text) | LinePart::Help(text) => Span::styled(text, style),
239✔
361
        }
362
      })
239✔
363
      .collect::<Vec<_>>(),
119✔
364
  )
365
}
119✔
366

367
pub fn get_gauge_symbol(enhanced_graphics: bool) -> &'static str {
12✔
368
  if enhanced_graphics {
12✔
369
    symbols::line::THICK_HORIZONTAL
4✔
370
  } else {
371
    symbols::line::HORIZONTAL
8✔
372
  }
373
}
12✔
374

375
pub fn table_header_style(cells: Vec<&str>, light: bool) -> Row<'_> {
9✔
376
  Row::new(cells).style(style_text(light)).bottom_margin(0)
9✔
377
}
9✔
378

379
pub fn horizontal_chunks(constraints: Vec<Constraint>, size: Rect) -> Rc<[Rect]> {
3✔
380
  Layout::default()
3✔
381
    .constraints(<Vec<Constraint> as AsRef<[Constraint]>>::as_ref(
3✔
382
      &constraints,
3✔
383
    ))
3✔
384
    .direction(Direction::Horizontal)
3✔
385
    .split(size)
3✔
386
}
3✔
387

388
pub fn horizontal_chunks_with_margin(
6✔
389
  constraints: Vec<Constraint>,
6✔
390
  size: Rect,
6✔
391
  margin: u16,
6✔
392
) -> Rc<[Rect]> {
6✔
393
  Layout::default()
6✔
394
    .constraints(<Vec<Constraint> as AsRef<[Constraint]>>::as_ref(
6✔
395
      &constraints,
6✔
396
    ))
6✔
397
    .direction(Direction::Horizontal)
6✔
398
    .margin(margin)
6✔
399
    .split(size)
6✔
400
}
6✔
401

402
pub fn vertical_chunks(constraints: Vec<Constraint>, size: Rect) -> Rc<[Rect]> {
11✔
403
  Layout::default()
11✔
404
    .constraints(<Vec<Constraint> as AsRef<[Constraint]>>::as_ref(
11✔
405
      &constraints,
11✔
406
    ))
11✔
407
    .direction(Direction::Vertical)
11✔
408
    .split(size)
11✔
409
}
11✔
410

411
pub fn vertical_chunks_with_margin(
8✔
412
  constraints: Vec<Constraint>,
8✔
413
  size: Rect,
8✔
414
  margin: u16,
8✔
415
) -> Rc<[Rect]> {
8✔
416
  Layout::default()
8✔
417
    .constraints(<Vec<Constraint> as AsRef<[Constraint]>>::as_ref(
8✔
418
      &constraints,
8✔
419
    ))
8✔
420
    .direction(Direction::Vertical)
8✔
421
    .margin(margin)
8✔
422
    .split(size)
8✔
423
}
8✔
424

425
pub fn layout_block(title: Span<'_>) -> Block<'_> {
8✔
426
  Block::default().borders(Borders::ALL).title(title)
8✔
427
}
8✔
428

429
pub fn layout_block_default(title: &str) -> Block<'_> {
8✔
430
  layout_block(title_style(title))
8✔
431
}
8✔
432

433
pub fn layout_block_default_line(title: Line<'_>) -> Block<'_> {
11✔
434
  Block::default().borders(Borders::ALL).title(title)
11✔
435
}
11✔
436

437
pub fn layout_block_active_line(title: Line<'_>, light: bool) -> Block<'_> {
5✔
438
  Block::default()
5✔
439
    .borders(Borders::ALL)
5✔
440
    .title(title)
5✔
441
    .style(style_secondary(light))
5✔
442
}
5✔
443

444
pub fn layout_block_active_span(title: Line<'_>, light: bool) -> Block<'_> {
2✔
445
  layout_block_active_line(title, light)
2✔
446
}
2✔
447

448
pub fn layout_block_top_border(title: Line<'_>) -> Block<'_> {
9✔
449
  Block::default().borders(Borders::TOP).title(title)
9✔
450
}
9✔
451

452
enum FilterDisplayState<'a> {
453
  Inactive,
454
  EditingEmpty,
455
  Value { filter: &'a str, active: bool },
456
}
457

458
fn filter_display_state(filter: &str, active: bool) -> FilterDisplayState<'_> {
19✔
459
  if active && filter.is_empty() {
19✔
460
    FilterDisplayState::EditingEmpty
×
461
  } else if !filter.is_empty() {
19✔
462
    FilterDisplayState::Value { filter, active }
7✔
463
  } else {
464
    FilterDisplayState::Inactive
12✔
465
  }
466
}
19✔
467

468
fn filter_display_parts(filter: &str, active: bool) -> Vec<LinePart<'_>> {
19✔
469
  let state = filter_display_state(filter, active);
19✔
470
  let inactive_text = action_hint("filter", DEFAULT_KEYBINDING.filter.key);
19✔
471
  let clear_suffix = format!(" | clear {} ", DEFAULT_KEYBINDING.esc.key);
19✔
472
  let edit_suffix = format!(" | edit {} ", DEFAULT_KEYBINDING.filter.key);
19✔
473

474
  match state {
19✔
475
    FilterDisplayState::Inactive => vec![help_part(inactive_text)],
12✔
476
    FilterDisplayState::EditingEmpty => {
477
      vec![help_part("[type to filter]"), help_part(clear_suffix)]
×
478
    }
479
    FilterDisplayState::Value {
480
      filter,
4✔
481
      active: true,
482
    } => vec![
4✔
483
      default_part(format!("[{}]", filter)),
4✔
484
      help_part(clear_suffix),
4✔
485
    ],
486
    FilterDisplayState::Value {
487
      filter,
3✔
488
      active: false,
489
    } => vec![
3✔
490
      default_part(format!("[{}]", filter)),
3✔
491
      help_part(edit_suffix),
3✔
492
    ],
493
  }
494
}
19✔
495

496
pub fn filter_status_parts(filter: &str, active: bool) -> Vec<LinePart<'_>> {
6✔
497
  filter_display_parts(filter, active)
6✔
498
}
6✔
499

500
pub fn owned_filter_status_parts(filter: &str, active: bool) -> Vec<LinePart<'static>> {
13✔
501
  filter_display_parts(filter, active)
13✔
502
    .into_iter()
13✔
503
    .map(|part| match part {
18✔
504
      LinePart::Default(text) => default_part(text.into_owned()),
5✔
505
      LinePart::Help(text) => help_part(text.into_owned()),
13✔
506
    })
18✔
507
    .collect()
13✔
508
}
13✔
509

510
pub fn title_with_dual_style<'a>(part_1: String, part_2: Line<'a>, light: bool) -> Line<'a> {
11✔
511
  let mut spans = vec![Span::styled(
11✔
512
    part_1,
11✔
513
    style_secondary(light).add_modifier(Modifier::BOLD),
11✔
514
  )];
515
  spans.extend(part_2.spans);
11✔
516
  Line::from(spans)
11✔
517
}
11✔
518

519
pub fn copy_and_escape_title_line<'a, S: Into<Cow<'a, str>>>(_target: S, light: bool) -> Line<'a> {
×
520
  mixed_bold_line(
×
521
    [
×
522
      help_part(format!(
×
523
        "{} | ",
×
524
        action_hint("copy", DEFAULT_KEYBINDING.copy_to_clipboard.key)
×
525
      )),
×
526
      help_part(format!("back {} ", DEFAULT_KEYBINDING.esc.key)),
×
527
    ],
×
528
    light,
×
529
  )
530
}
×
531

532
pub fn copy_scroll_and_escape_title_line<'a, S: Into<Cow<'a, str>>>(
×
533
  _target: S,
×
534
  auto_scroll: bool,
×
535
  light: bool,
×
536
) -> Line<'a> {
×
537
  let auto_scroll_action = if auto_scroll {
×
538
    "pause scroll"
×
539
  } else {
540
    "resume scroll"
×
541
  };
542
  mixed_bold_line(
×
543
    [
×
544
      help_part(format!(
×
545
        "{} | {} | ",
×
546
        action_hint("copy", DEFAULT_KEYBINDING.copy_to_clipboard.key),
×
547
        action_hint(auto_scroll_action, DEFAULT_KEYBINDING.log_auto_scroll.key)
×
548
      )),
×
549
      help_part(format!("back {} ", DEFAULT_KEYBINDING.esc.key)),
×
550
    ],
×
551
    light,
×
552
  )
553
}
×
554

555
pub fn split_hint_suffix(text: &str) -> (&str, Option<&str>) {
79✔
556
  if let Some(pos) = text.rfind(" <") {
79✔
557
    (&text[..pos], Some(&text[(pos + 1)..]))
79✔
558
  } else {
559
    (text, None)
×
560
  }
561
}
79✔
562

563
/// helper function to create a centered rect using up
564
/// certain percentage of the available rect `r`
565
pub fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
4✔
566
  let Rect {
567
    width: grid_width,
4✔
568
    height: grid_height,
4✔
569
    ..
570
  } = r;
4✔
571
  let outer_height = (grid_height / 2).saturating_sub(height / 2);
4✔
572

573
  let popup_layout = Layout::default()
4✔
574
    .direction(Direction::Vertical)
4✔
575
    .constraints(
4✔
576
      [
4✔
577
        Constraint::Length(outer_height),
4✔
578
        Constraint::Length(height),
4✔
579
        Constraint::Length(outer_height),
4✔
580
      ]
4✔
581
      .as_ref(),
4✔
582
    )
4✔
583
    .split(r);
4✔
584

585
  let outer_width = (grid_width / 2).saturating_sub(width / 2);
4✔
586

587
  Layout::default()
4✔
588
    .direction(Direction::Horizontal)
4✔
589
    .constraints(
4✔
590
      [
4✔
591
        Constraint::Length(outer_width),
4✔
592
        Constraint::Length(width),
4✔
593
        Constraint::Length(outer_width),
4✔
594
      ]
4✔
595
      .as_ref(),
4✔
596
    )
4✔
597
    .split(popup_layout[1])[1]
4✔
598
}
4✔
599

600
pub fn loading(f: &mut Frame<'_>, block: Block<'_>, area: Rect, is_loading: bool, light: bool) {
9✔
601
  if is_loading {
9✔
602
    let text = "\n\n Loading ...\n\n".to_owned();
×
603
    let text = Text::from(text);
×
604
    let text = text.patch_style(style_secondary(light));
×
605

×
606
    // Contains the text
×
607
    let paragraph = Paragraph::new(text)
×
608
      .style(style_secondary(light))
×
609
      .block(block);
×
610
    f.render_widget(paragraph, area);
×
611
  } else {
×
612
    f.render_widget(block, area)
9✔
613
  }
614
}
9✔
615

616
// using a macro to reuse code as generics will make handling lifetimes a PITA
617
#[macro_export]
618
macro_rules! draw_resource_tab {
619
  ($title:expr, $block:expr, $f:expr, $app:expr, $area:expr, $fn1:expr, $fn2:expr, $res:expr) => {
620
    match $block {
621
      ActiveBlock::Describe => draw_describe_block(
622
        $f,
623
        $app,
624
        $area,
625
        title_with_dual_style(
626
          get_resource_title($app, $title, get_describe_active($block), $res.items.len()),
627
          $crate::ui::utils::copy_and_escape_title_line($title, $app.light_theme),
628
          $app.light_theme,
629
        ),
630
      ),
631
      ActiveBlock::Yaml => draw_yaml_block(
632
        $f,
633
        $app,
634
        $area,
635
        title_with_dual_style(
636
          get_resource_title($app, $title, get_describe_active($block), $res.items.len()),
637
          $crate::ui::utils::copy_and_escape_title_line($title, $app.light_theme),
638
          $app.light_theme,
639
        ),
640
      ),
641
      ActiveBlock::Pods => $crate::app::pods::draw_block_as_sub($f, $app, $area),
642
      ActiveBlock::Containers => $crate::app::pods::draw_containers_block($f, $app, $area),
643
      ActiveBlock::Logs => $crate::app::pods::draw_logs_block($f, $app, $area),
644
      ActiveBlock::Namespaces => $fn1($app.get_prev_route().active_block, $f, $app, $area),
645
      _ => $fn2($f, $app, $area),
646
    };
647
  };
648
}
649

650
pub struct ResourceTableProps<'a, T> {
651
  pub title: String,
652
  pub inline_help: Line<'a>,
653
  pub resource: &'a mut StatefulTable<T>,
654
  pub table_headers: Vec<&'a str>,
655
  pub column_widths: Vec<Constraint>,
656
}
657
/// common for all resources
658
pub fn draw_describe_block(f: &mut Frame<'_>, app: &mut App, area: Rect, title: Line<'_>) {
×
659
  draw_yaml_block(f, app, area, title);
×
660
}
×
661

662
/// Refreshes the syntax-highlight cache when empty or the theme changed.
663
/// Returns `false` when there is no content to highlight.
NEW
664
fn ensure_highlight_cache(app: &mut App) -> bool {
×
NEW
665
  if app.data.describe_out.get_txt().is_empty() {
×
NEW
666
    return false;
×
NEW
667
  }
×
NEW
668
  if app.data.describe_out.highlighted_lines.is_empty()
×
NEW
669
    || app.data.describe_out.highlight_light_theme != app.light_theme
×
670
  {
NEW
671
    let ss = get_syntax_set();
×
NEW
672
    let syntax = get_yaml_syntax_reference();
×
NEW
673
    let theme = if app.light_theme {
×
NEW
674
      &get_yaml_themes().light
×
675
    } else {
NEW
676
      &get_yaml_themes().dark
×
677
    };
NEW
678
    let mut h = syntect::easy::HighlightLines::new(syntax, theme);
×
NEW
679
    let txt = app.data.describe_out.get_txt();
×
NEW
680
    let lines: Vec<_> = syntect::util::LinesWithEndings::from(txt)
×
NEW
681
      .filter_map(|line| match h.highlight_line(line, ss) {
×
NEW
682
        Ok(segments) => {
×
NEW
683
          let line_spans: Vec<_> = segments
×
NEW
684
            .into_iter()
×
NEW
685
            .filter_map(syntect_to_ratatui_span_owned)
×
NEW
686
            .collect();
×
NEW
687
          Some(ratatui::text::Line::from(line_spans))
×
688
        }
NEW
689
        Err(_) => None,
×
NEW
690
      })
×
NEW
691
      .collect();
×
NEW
692
    app.data.describe_out.highlighted_lines = lines;
×
NEW
693
    app.data.describe_out.highlight_light_theme = app.light_theme;
×
NEW
694
  }
×
NEW
695
  true
×
NEW
696
}
×
697

698
/// common for all resources
699
pub fn draw_yaml_block(f: &mut Frame<'_>, app: &mut App, area: Rect, title: Line<'_>) {
×
700
  let block = layout_block_top_border(title);
×
NEW
701
  if ensure_highlight_cache(app) {
×
NEW
702
    let offset = app.data.describe_out.offset;
×
NEW
703
    let total = app.data.describe_out.highlighted_lines.len();
×
NEW
704
    // Subtract 2 for the top-border of the block.
×
NEW
705
    let view_h = area.height.saturating_sub(2) as usize;
×
NEW
706
    // Take a generous window around the visible region.
×
NEW
707
    let slice_start = offset.saturating_sub(view_h);
×
NEW
708
    let slice_end = total.min(offset + view_h * 3);
×
NEW
709
    let adjusted_offset = (offset - slice_start).min(u16::MAX as usize) as u16;
×
NEW
710
    let visible_lines = app.data.describe_out.highlighted_lines[slice_start..slice_end].to_vec();
×
NEW
711
    let paragraph = Paragraph::new(visible_lines)
×
712
      .block(block)
×
713
      .wrap(Wrap { trim: false })
×
NEW
714
      .scroll((adjusted_offset, 0));
×
715
    f.render_widget(paragraph, area);
×
716
  } else {
×
717
    loading(f, block, area, app.is_loading(), app.light_theme);
×
718
  }
×
719
}
×
720

721
fn draw_resource_table<'a, T: KubeResource<U>, F, U: Serialize>(
10✔
722
  f: &mut Frame<'_>,
10✔
723
  area: Rect,
10✔
724
  table_props: ResourceTableProps<'a, T>,
10✔
725
  row_cell_mapper: F,
10✔
726
  light_theme: bool,
10✔
727
  is_loading: bool,
10✔
728
  block: Block<'a>,
10✔
729
) where
10✔
730
  F: Fn(&T) -> Row<'a>,
10✔
731
{
732
  if !table_props.resource.items.is_empty() {
10✔
733
    let filter = table_props.resource.filter.to_lowercase();
7✔
734
    let has_filter = !filter.is_empty();
7✔
735
    let mut filtered_indices: Vec<usize> = Vec::new();
7✔
736

737
    let filtered_items: Vec<(usize, &T)> = table_props
7✔
738
      .resource
7✔
739
      .items
7✔
740
      .iter()
7✔
741
      .enumerate()
7✔
742
      .filter(|(_, c)| filter.is_empty() || filter_by_name(&filter, *c))
32✔
743
      .inspect(|(idx, _)| {
29✔
744
        if has_filter {
29✔
745
          filtered_indices.push(*idx);
5✔
746
        }
24✔
747
      })
29✔
748
      .collect();
7✔
749

750
    if has_filter {
7✔
751
      let max = filtered_items.len().saturating_sub(1);
4✔
752
      if let Some(sel) = table_props.resource.state.selected() {
4✔
753
        if sel > max {
4✔
754
          table_props.resource.state.select(Some(max));
×
755
        }
4✔
756
      }
×
757
    }
3✔
758
    table_props.resource.filtered_indices = filtered_indices;
7✔
759

760
    // Determine the visible row range to avoid expensive row_cell_mapper
761
    // calls for off-screen items.  Subtract 3 for header + borders.
762
    let selected = table_props.resource.state.selected().unwrap_or(0);
7✔
763
    let view_h = area.height.saturating_sub(3) as usize;
7✔
764
    let visible_start = selected.saturating_sub(view_h);
7✔
765
    let visible_end = (selected + view_h * 2).min(filtered_items.len());
7✔
766

767
    let rows: Vec<Row<'a>> = filtered_items
7✔
768
      .iter()
7✔
769
      .enumerate()
7✔
770
      .map(|(fi, (_orig_idx, item))| {
29✔
771
        if fi >= visible_start && fi < visible_end {
29✔
772
          row_cell_mapper(item)
29✔
773
        } else {
NEW
774
          Row::default()
×
775
        }
776
      })
29✔
777
      .collect();
7✔
778

779
    let table = Table::new(rows, &table_props.column_widths)
7✔
780
      .header(table_header_style(table_props.table_headers, light_theme))
7✔
781
      .block(block)
7✔
782
      .row_highlight_style(style_highlight())
7✔
783
      .highlight_symbol(HIGHLIGHT);
7✔
784

785
    f.render_stateful_widget(table, area, &mut table_props.resource.state);
7✔
786
  } else {
3✔
787
    loading(f, block, area, is_loading, light_theme);
3✔
788
  }
3✔
789
}
10✔
790

791
/// Builds the help `Line` for a resource block title, weaving filter status
792
/// into any existing inline help (placing it after a "containers" prefix when present).
793
fn build_resource_help_line(
11✔
794
  inline_help: Line<'_>,
11✔
795
  filter: &str,
11✔
796
  filter_active: bool,
11✔
797
  light_theme: bool,
11✔
798
) -> Line<'static> {
11✔
799
  let inline_help_text = inline_help
11✔
800
    .spans
11✔
801
    .iter()
11✔
802
    .map(|span| span.content.as_ref())
11✔
803
    .collect::<String>();
11✔
804
  let containers_prefix = format!(
11✔
805
    "{} | ",
806
    action_hint("containers", DEFAULT_KEYBINDING.submit.key)
11✔
807
  );
808
  let mut help_parts: Vec<LinePart<'static>> = Vec::new();
11✔
809
  if let Some(rest) = inline_help_text.strip_prefix(&containers_prefix) {
11✔
810
    help_parts.push(help_part(containers_prefix));
5✔
811
    help_parts.extend(owned_filter_status_parts(filter, filter_active));
5✔
812
    if !rest.is_empty() {
5✔
813
      help_parts.push(help_part(" | ".to_string()));
4✔
814
      help_parts.push(help_part(rest.to_string()));
4✔
815
    }
4✔
816
  } else {
817
    help_parts.extend(owned_filter_status_parts(filter, filter_active));
6✔
818
    if !inline_help_text.is_empty() {
6✔
819
      help_parts.push(help_part(" | ".to_string()));
4✔
820
      help_parts.push(help_part(inline_help_text));
4✔
821
    }
4✔
822
  }
823
  mixed_bold_line(help_parts, light_theme)
11✔
824
}
11✔
825

826
/// Draw a kubernetes resource overview tab
827
pub fn draw_resource_block<'a, T: KubeResource<U>, F, U: Serialize>(
9✔
828
  f: &mut Frame<'_>,
9✔
829
  area: Rect,
9✔
830
  table_props: ResourceTableProps<'a, T>,
9✔
831
  row_cell_mapper: F,
9✔
832
  light_theme: bool,
9✔
833
  is_loading: bool,
9✔
834
) where
9✔
835
  F: Fn(&T) -> Row<'a>,
9✔
836
{
837
  let ResourceTableProps {
838
    title,
9✔
839
    inline_help,
9✔
840
    resource,
9✔
841
    table_headers,
9✔
842
    column_widths,
9✔
843
  } = table_props;
9✔
844
  let filter = resource.filter.clone();
9✔
845
  let filter_active = resource.filter_active;
9✔
846
  if filter_active {
9✔
847
    let title_width = title.chars().count();
2✔
848
    let title = title_with_dual_style(
2✔
849
      title,
2✔
850
      mixed_bold_line(owned_filter_status_parts(&filter, true), light_theme),
2✔
851
      light_theme,
2✔
852
    );
853
    let block = layout_block_top_border(title);
2✔
854
    draw_resource_table(
2✔
855
      f,
2✔
856
      area,
2✔
857
      ResourceTableProps {
2✔
858
        title: String::new(),
2✔
859
        inline_help: Line::default(),
2✔
860
        resource,
2✔
861
        table_headers,
2✔
862
        column_widths,
2✔
863
      },
2✔
864
      row_cell_mapper,
2✔
865
      light_theme,
2✔
866
      is_loading,
2✔
867
      block,
2✔
868
    );
869
    f.set_cursor_position(filter_cursor_position(area, title_width, &filter));
2✔
870
    return;
2✔
871
  }
7✔
872

873
  let help_line = build_resource_help_line(inline_help, &filter, filter_active, light_theme);
7✔
874
  let title = title_with_dual_style(title, help_line, light_theme);
7✔
875
  let block = layout_block_top_border(title);
7✔
876
  draw_resource_table(
7✔
877
    f,
7✔
878
    area,
7✔
879
    ResourceTableProps {
7✔
880
      title: String::new(),
7✔
881
      inline_help: Line::default(),
7✔
882
      resource,
7✔
883
      table_headers,
7✔
884
      column_widths,
7✔
885
    },
7✔
886
    row_cell_mapper,
7✔
887
    light_theme,
7✔
888
    is_loading,
7✔
889
    block,
7✔
890
  );
891
}
9✔
892

893
pub fn draw_route_resource_block<'a, T: KubeResource<U>, F, U: Serialize>(
1✔
894
  f: &mut Frame<'_>,
1✔
895
  area: Rect,
1✔
896
  table_props: ResourceTableProps<'a, T>,
1✔
897
  row_cell_mapper: F,
1✔
898
  light_theme: bool,
1✔
899
  is_loading: bool,
1✔
900
) where
1✔
901
  F: Fn(&T) -> Row<'a>,
1✔
902
{
903
  let ResourceTableProps {
904
    title,
1✔
905
    inline_help,
1✔
906
    resource,
1✔
907
    table_headers,
1✔
908
    column_widths,
1✔
909
  } = table_props;
1✔
910
  let title = title_with_dual_style(title, inline_help, light_theme);
1✔
911
  let block = layout_block_active_span(title, light_theme);
1✔
912
  draw_resource_table(
1✔
913
    f,
1✔
914
    area,
1✔
915
    ResourceTableProps {
1✔
916
      title: String::new(),
1✔
917
      inline_help: Line::default(),
1✔
918
      resource,
1✔
919
      table_headers,
1✔
920
      column_widths,
1✔
921
    },
1✔
922
    row_cell_mapper,
1✔
923
    light_theme,
1✔
924
    is_loading,
1✔
925
    block,
1✔
926
  );
927
}
1✔
928

929
pub fn filter_by_resource_name<T: KubeResource<U>, U: Serialize>(
7✔
930
  filter: &str,
7✔
931
  res: &T,
7✔
932
  row_cell_mapper: Row<'static>,
7✔
933
) -> Option<Row<'static>> {
7✔
934
  if filter.is_empty() || filter_by_name(filter, res) {
7✔
935
    Some(row_cell_mapper)
7✔
936
  } else {
937
    None
×
938
  }
939
}
7✔
940

941
pub fn text_matches_filter(filter: &str, value: &str) -> bool {
198✔
942
  let filter = filter.to_lowercase();
198✔
943
  let value = value.to_lowercase();
198✔
944
  filter.is_empty() || glob_match(&filter, &value) || value.contains(&filter)
198✔
945
}
198✔
946

947
fn filter_by_name<T: KubeResource<U>, U: Serialize>(ft: &str, res: &T) -> bool {
9✔
948
  text_matches_filter(ft, res.get_name())
9✔
949
}
9✔
950

951
pub fn get_cluster_wide_resource_title<S: AsRef<str>>(
2✔
952
  title: S,
2✔
953
  items_len: usize,
2✔
954
  suffix: S,
2✔
955
) -> String {
2✔
956
  format!(" {} [{}] {}", title.as_ref(), items_len, suffix.as_ref())
2✔
957
}
2✔
958

959
pub fn get_resource_title<S: AsRef<str>>(
9✔
960
  app: &App,
9✔
961
  title: S,
9✔
962
  suffix: S,
9✔
963
  items_len: usize,
9✔
964
) -> String {
9✔
965
  format!(
9✔
966
    " {} {}",
967
    title_with_ns(
9✔
968
      title.as_ref(),
9✔
969
      app
9✔
970
        .data
9✔
971
        .selected
9✔
972
        .ns
9✔
973
        .as_ref()
9✔
974
        .unwrap_or(&String::from("all")),
9✔
975
      items_len
9✔
976
    ),
977
    suffix.as_ref(),
9✔
978
  )
979
}
9✔
980

981
static DESCRIBE_ACTIVE: &str = "-> Describe ";
982
static YAML_ACTIVE: &str = "-> YAML ";
983

984
pub fn get_describe_active<'a>(block: ActiveBlock) -> &'a str {
×
985
  match block {
×
986
    ActiveBlock::Describe => DESCRIBE_ACTIVE,
×
987
    _ => YAML_ACTIVE,
×
988
  }
989
}
×
990

991
pub fn title_with_ns(title: &str, ns: &str, length: usize) -> String {
10✔
992
  format!("{} (ns: {}) [{}]", title, ns, length)
10✔
993
}
10✔
994

995
#[cfg(test)]
996
mod tests {
997
  use ratatui::{
998
    backend::TestBackend, buffer::Buffer, layout::Position, style::Modifier, widgets::Cell,
999
    Terminal,
1000
  };
1001

1002
  use super::*;
1003
  use crate::ui::utils::{MACCHIATO_BLUE, MACCHIATO_MAUVE, MACCHIATO_TEXT, MACCHIATO_YELLOW};
1004

1005
  #[test]
1006
  fn test_draw_resource_block() {
1✔
1007
    let backend = TestBackend::new(100, 6);
1✔
1008
    let mut terminal = Terminal::new(backend).unwrap();
1✔
1009

1010
    struct RenderTest {
1011
      pub name: String,
1012
      pub namespace: String,
1013
      pub data: i32,
1014
      pub age: String,
1015
    }
1016

1017
    impl KubeResource<Option<String>> for RenderTest {
1018
      fn get_name(&self) -> &String {
×
1019
        &self.name
×
1020
      }
×
1021
      fn get_k8s_obj(&self) -> &Option<String> {
×
1022
        &None
×
1023
      }
×
1024
    }
1025
    terminal
1✔
1026
      .draw(|f| {
1✔
1027
        let size = f.area();
1✔
1028
        let mut resource: StatefulTable<RenderTest> = StatefulTable::new();
1✔
1029
        resource.set_items(vec![
1✔
1030
          RenderTest {
1✔
1031
            name: "Test 1".into(),
1✔
1032
            namespace: "Test ns".into(),
1✔
1033
            age: "65h3m".into(),
1✔
1034
            data: 5,
1✔
1035
          },
1✔
1036
          RenderTest {
1✔
1037
            name: "Test long name that should be truncated from view".into(),
1✔
1038
            namespace: "Test ns".into(),
1✔
1039
            age: "65h3m".into(),
1✔
1040
            data: 3,
1✔
1041
          },
1✔
1042
          RenderTest {
1✔
1043
            name: "test_long_name_that_should_be_truncated_from_view".into(),
1✔
1044
            namespace: "Test ns long value check that should be truncated".into(),
1✔
1045
            age: "65h3m".into(),
1✔
1046
            data: 6,
1✔
1047
          },
1✔
1048
        ]);
1049
        draw_resource_block(
1✔
1050
          f,
1✔
1051
          size,
1✔
1052
          ResourceTableProps {
1✔
1053
            title: "Test".into(),
1✔
1054
            inline_help: help_bold_line("-> yaml <y>", false),
1✔
1055
            resource: &mut resource,
1✔
1056
            table_headers: vec!["Namespace", "Name", "Data", "Age"],
1✔
1057
            column_widths: vec![
1✔
1058
              Constraint::Percentage(30),
1✔
1059
              Constraint::Percentage(40),
1✔
1060
              Constraint::Percentage(15),
1✔
1061
              Constraint::Percentage(15),
1✔
1062
            ],
1✔
1063
          },
1✔
1064
          |c| {
3✔
1065
            Row::new(vec![
3✔
1066
              Cell::from(c.namespace.to_owned()),
3✔
1067
              Cell::from(c.name.to_owned()),
3✔
1068
              Cell::from(c.data.to_string()),
3✔
1069
              Cell::from(c.age.to_owned()),
3✔
1070
            ])
1071
            .style(style_primary(false))
3✔
1072
          },
3✔
1073
          false,
1074
          false,
1075
        );
1076
      })
1✔
1077
      .unwrap();
1✔
1078

1079
    let mut expected = Buffer::with_lines(vec![
1✔
1080
        "Testfilter </> | -> yaml <y>────────────────────────────────────────────────────────────────────────",
1081
        "   Namespace                     Name                                 Data           Age            ",
1✔
1082
        "=> Test ns                       Test 1                               5              65h3m          ",
1✔
1083
        "   Test ns                       Test long name that should be trunca 3              65h3m          ",
1✔
1084
        "   Test ns long value check that test_long_name_that_should_be_trunca 6              65h3m          ",
1✔
1085
        "                                                                                                    ",
1✔
1086
      ]);
1087
    // set row styles
1088
    // First row heading style
1089
    for col in 0..=99 {
100✔
1090
      match col {
100✔
1091
        0..=3 => {
100✔
1092
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
4✔
1093
            Style::default()
4✔
1094
              .fg(MACCHIATO_YELLOW)
4✔
1095
              .add_modifier(Modifier::BOLD),
4✔
1096
          );
4✔
1097
        }
4✔
1098
        4..=27 => {
96✔
1099
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
24✔
1100
            Style::default()
24✔
1101
              .fg(MACCHIATO_BLUE)
24✔
1102
              .add_modifier(Modifier::BOLD),
24✔
1103
          );
24✔
1104
        }
24✔
1105
        _ => {}
72✔
1106
      }
1107
    }
1108

1109
    // Second row table header style
1110
    for col in 0..=99 {
100✔
1111
      expected
100✔
1112
        .cell_mut(Position::new(col, 1))
100✔
1113
        .unwrap()
100✔
1114
        .set_style(Style::default().fg(MACCHIATO_TEXT));
100✔
1115
    }
100✔
1116
    // first table data row style
1117
    for col in 0..=99 {
100✔
1118
      expected.cell_mut(Position::new(col, 2)).unwrap().set_style(
100✔
1119
        Style::default()
100✔
1120
          .fg(MACCHIATO_MAUVE)
100✔
1121
          .add_modifier(Modifier::REVERSED),
100✔
1122
      );
100✔
1123
    }
100✔
1124
    // remaining table data row style
1125
    for row in 3..=4 {
2✔
1126
      for col in 0..=99 {
200✔
1127
        expected
200✔
1128
          .cell_mut(Position::new(col, row))
200✔
1129
          .unwrap()
200✔
1130
          .set_style(Style::default().fg(MACCHIATO_MAUVE));
200✔
1131
      }
200✔
1132
    }
1133

1134
    terminal.backend().assert_buffer(&expected);
1✔
1135
  }
1✔
1136

1137
  #[test]
1138
  fn test_draw_resource_block_filter() {
1✔
1139
    let backend = TestBackend::new(100, 6);
1✔
1140
    let mut terminal = Terminal::new(backend).unwrap();
1✔
1141

1142
    struct RenderTest {
1143
      pub name: String,
1144
      pub namespace: String,
1145
      pub data: i32,
1146
      pub age: String,
1147
    }
1148
    impl KubeResource<Option<String>> for RenderTest {
1149
      fn get_name(&self) -> &String {
3✔
1150
        &self.name
3✔
1151
      }
3✔
1152
      fn get_k8s_obj(&self) -> &Option<String> {
×
1153
        &None
×
1154
      }
×
1155
    }
1156

1157
    terminal
1✔
1158
      .draw(|f| {
1✔
1159
        let size = f.area();
1✔
1160
        let mut resource: StatefulTable<RenderTest> = StatefulTable::new();
1✔
1161
        resource.set_items(vec![
1✔
1162
          RenderTest {
1✔
1163
            name: "Test 1".into(),
1✔
1164
            namespace: "Test ns".into(),
1✔
1165
            age: "65h3m".into(),
1✔
1166
            data: 5,
1✔
1167
          },
1✔
1168
          RenderTest {
1✔
1169
            name: "Test long name that should be truncated from view".into(),
1✔
1170
            namespace: "Test ns".into(),
1✔
1171
            age: "65h3m".into(),
1✔
1172
            data: 3,
1✔
1173
          },
1✔
1174
          RenderTest {
1✔
1175
            name: "test_long_name_that_should_be_truncated_from_view".into(),
1✔
1176
            namespace: "Test ns long value check that should be truncated".into(),
1✔
1177
            age: "65h3m".into(),
1✔
1178
            data: 6,
1✔
1179
          },
1✔
1180
        ]);
1181
        resource.filter = "truncated".to_string();
1✔
1182
        draw_resource_block(
1✔
1183
          f,
1✔
1184
          size,
1✔
1185
          ResourceTableProps {
1✔
1186
            title: "Test".into(),
1✔
1187
            inline_help: help_bold_line("-> yaml <y>", false),
1✔
1188
            resource: &mut resource,
1✔
1189
            table_headers: vec!["Namespace", "Name", "Data", "Age"],
1✔
1190
            column_widths: vec![
1✔
1191
              Constraint::Percentage(30),
1✔
1192
              Constraint::Percentage(40),
1✔
1193
              Constraint::Percentage(15),
1✔
1194
              Constraint::Percentage(15),
1✔
1195
            ],
1✔
1196
          },
1✔
1197
          |c| {
2✔
1198
            Row::new(vec![
2✔
1199
              Cell::from(c.namespace.to_owned()),
2✔
1200
              Cell::from(c.name.to_owned()),
2✔
1201
              Cell::from(c.data.to_string()),
2✔
1202
              Cell::from(c.age.to_owned()),
2✔
1203
            ])
1204
            .style(style_primary(false))
2✔
1205
          },
2✔
1206
          false,
1207
          false,
1208
        );
1209
      })
1✔
1210
      .unwrap();
1✔
1211

1212
    let mut expected = Buffer::with_lines(vec![
1✔
1213
        "Test[truncated] | edit </>  | -> yaml <y>───────────────────────────────────────────────────────────",
1214
        "   Namespace                     Name                                 Data           Age            ",
1✔
1215
        "=> Test ns                       Test long name that should be trunca 3              65h3m          ",
1✔
1216
        "   Test ns long value check that test_long_name_that_should_be_trunca 6              65h3m          ",
1✔
1217
        "                                                                                                    ",
1✔
1218
        "                                                                                                    ",
1✔
1219
      ]);
1220
    // set row styles
1221
    // First row heading style
1222
    for col in 0..=99 {
100✔
1223
      match col {
100✔
1224
        0..=3 => {
100✔
1225
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
4✔
1226
            Style::default()
4✔
1227
              .fg(MACCHIATO_YELLOW)
4✔
1228
              .add_modifier(Modifier::BOLD),
4✔
1229
          );
4✔
1230
        }
4✔
1231
        4..=14 => {
96✔
1232
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
11✔
1233
            Style::default()
11✔
1234
              .fg(MACCHIATO_TEXT)
11✔
1235
              .add_modifier(Modifier::BOLD),
11✔
1236
          );
11✔
1237
        }
11✔
1238
        15..=40 => {
85✔
1239
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
26✔
1240
            Style::default()
26✔
1241
              .fg(MACCHIATO_BLUE)
26✔
1242
              .add_modifier(Modifier::BOLD),
26✔
1243
          );
26✔
1244
        }
26✔
1245
        _ => {}
59✔
1246
      }
1247
    }
1248

1249
    // Second row table header style
1250
    for col in 0..=99 {
100✔
1251
      expected
100✔
1252
        .cell_mut(Position::new(col, 1))
100✔
1253
        .unwrap()
100✔
1254
        .set_style(Style::default().fg(MACCHIATO_TEXT));
100✔
1255
    }
100✔
1256
    // first table data row style
1257
    for col in 0..=99 {
100✔
1258
      expected.cell_mut(Position::new(col, 2)).unwrap().set_style(
100✔
1259
        Style::default()
100✔
1260
          .fg(MACCHIATO_MAUVE)
100✔
1261
          .add_modifier(Modifier::REVERSED),
100✔
1262
      );
100✔
1263
    }
100✔
1264
    // remaining table data row style
1265
    for row in 3..=3 {
1✔
1266
      for col in 0..=99 {
100✔
1267
        expected
100✔
1268
          .cell_mut(Position::new(col, row))
100✔
1269
          .unwrap()
100✔
1270
          .set_style(Style::default().fg(MACCHIATO_MAUVE));
100✔
1271
      }
100✔
1272
    }
1273

1274
    terminal.backend().assert_buffer(&expected);
1✔
1275
  }
1✔
1276

1277
  #[test]
1278
  fn test_draw_resource_block_filter_glob() {
1✔
1279
    let backend = TestBackend::new(100, 6);
1✔
1280
    let mut terminal = Terminal::new(backend).unwrap();
1✔
1281

1282
    struct RenderTest {
1283
      pub name: String,
1284
      pub namespace: String,
1285
      pub data: i32,
1286
      pub age: String,
1287
    }
1288
    impl KubeResource<Option<String>> for RenderTest {
1289
      fn get_name(&self) -> &String {
3✔
1290
        &self.name
3✔
1291
      }
3✔
1292
      fn get_k8s_obj(&self) -> &Option<String> {
×
1293
        &None
×
1294
      }
×
1295
    }
1296

1297
    terminal
1✔
1298
      .draw(|f| {
1✔
1299
        let size = f.area();
1✔
1300
        let mut resource: StatefulTable<RenderTest> = StatefulTable::new();
1✔
1301
        resource.set_items(vec![
1✔
1302
          RenderTest {
1✔
1303
            name: "Test 1".into(),
1✔
1304
            namespace: "Test ns".into(),
1✔
1305
            age: "65h3m".into(),
1✔
1306
            data: 5,
1✔
1307
          },
1✔
1308
          RenderTest {
1✔
1309
            name: "Test long name that should be truncated from view".into(),
1✔
1310
            namespace: "Test ns".into(),
1✔
1311
            age: "65h3m".into(),
1✔
1312
            data: 3,
1✔
1313
          },
1✔
1314
          RenderTest {
1✔
1315
            name: "test_long_name_that_should_be_truncated_from_view".into(),
1✔
1316
            namespace: "Test ns long value check that should be truncated".into(),
1✔
1317
            age: "65h3m".into(),
1✔
1318
            data: 6,
1✔
1319
          },
1✔
1320
        ]);
1321
        resource.filter = "*long*truncated*".to_string();
1✔
1322
        draw_resource_block(
1✔
1323
          f,
1✔
1324
          size,
1✔
1325
          ResourceTableProps {
1✔
1326
            title: "Test".into(),
1✔
1327
            inline_help: help_bold_line("-> yaml <y>", false),
1✔
1328
            resource: &mut resource,
1✔
1329
            table_headers: vec!["Namespace", "Name", "Data", "Age"],
1✔
1330
            column_widths: vec![
1✔
1331
              Constraint::Percentage(30),
1✔
1332
              Constraint::Percentage(40),
1✔
1333
              Constraint::Percentage(15),
1✔
1334
              Constraint::Percentage(15),
1✔
1335
            ],
1✔
1336
          },
1✔
1337
          |c| {
2✔
1338
            Row::new(vec![
2✔
1339
              Cell::from(c.namespace.to_owned()),
2✔
1340
              Cell::from(c.name.to_owned()),
2✔
1341
              Cell::from(c.data.to_string()),
2✔
1342
              Cell::from(c.age.to_owned()),
2✔
1343
            ])
1344
            .style(style_primary(false))
2✔
1345
          },
2✔
1346
          false,
1347
          false,
1348
        );
1349
      })
1✔
1350
      .unwrap();
1✔
1351

1352
    let mut expected = Buffer::with_lines(vec![
1✔
1353
        "Test[*long*truncated*] | edit </>  | -> yaml <y>────────────────────────────────────────────────────",
1354
        "   Namespace                     Name                                 Data           Age            ",
1✔
1355
        "=> Test ns                       Test long name that should be trunca 3              65h3m          ",
1✔
1356
        "   Test ns long value check that test_long_name_that_should_be_trunca 6              65h3m          ",
1✔
1357
        "                                                                                                    ",
1✔
1358
        "                                                                                                    ",
1✔
1359
      ]);
1360
    // set row styles
1361
    // First row heading style
1362
    for col in 0..=99 {
100✔
1363
      match col {
100✔
1364
        0..=3 => {
100✔
1365
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
4✔
1366
            Style::default()
4✔
1367
              .fg(MACCHIATO_YELLOW)
4✔
1368
              .add_modifier(Modifier::BOLD),
4✔
1369
          );
4✔
1370
        }
4✔
1371
        4..=21 => {
96✔
1372
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
18✔
1373
            Style::default()
18✔
1374
              .fg(MACCHIATO_TEXT)
18✔
1375
              .add_modifier(Modifier::BOLD),
18✔
1376
          );
18✔
1377
        }
18✔
1378
        22..=47 => {
78✔
1379
          expected.cell_mut(Position::new(col, 0)).unwrap().set_style(
26✔
1380
            Style::default()
26✔
1381
              .fg(MACCHIATO_BLUE)
26✔
1382
              .add_modifier(Modifier::BOLD),
26✔
1383
          );
26✔
1384
        }
26✔
1385
        _ => {}
52✔
1386
      }
1387
    }
1388

1389
    // Second row table header style
1390
    for col in 0..=99 {
100✔
1391
      expected
100✔
1392
        .cell_mut(Position::new(col, 1))
100✔
1393
        .unwrap()
100✔
1394
        .set_style(Style::default().fg(MACCHIATO_TEXT));
100✔
1395
    }
100✔
1396
    // first table data row style
1397
    for col in 0..=99 {
100✔
1398
      expected.cell_mut(Position::new(col, 2)).unwrap().set_style(
100✔
1399
        Style::default()
100✔
1400
          .fg(MACCHIATO_MAUVE)
100✔
1401
          .add_modifier(Modifier::REVERSED),
100✔
1402
      );
100✔
1403
    }
100✔
1404
    // remaining table data row style
1405
    for row in 3..=3 {
1✔
1406
      for col in 0..=99 {
100✔
1407
        expected
100✔
1408
          .cell_mut(Position::new(col, row))
100✔
1409
          .unwrap()
100✔
1410
          .set_style(Style::default().fg(MACCHIATO_MAUVE));
100✔
1411
      }
100✔
1412
    }
1413

1414
    terminal.backend().assert_buffer(&expected);
1✔
1415
  }
1✔
1416

1417
  #[test]
1418
  fn test_get_resource_title() {
1✔
1419
    let app = App::default();
1✔
1420
    assert_eq!(
1✔
1421
      get_resource_title(&app, "Title", "-> hello", 5),
1✔
1422
      " Title (ns: all) [5] -> hello"
1423
    );
1424
  }
1✔
1425

1426
  #[test]
1427
  fn test_draw_resource_block_filter_hides_other_hints_when_active() {
1✔
1428
    let backend = TestBackend::new(100, 4);
1✔
1429
    let mut terminal = Terminal::new(backend).unwrap();
1✔
1430

1431
    struct RenderTest {
1432
      pub name: String,
1433
    }
1434

1435
    impl KubeResource<Option<String>> for RenderTest {
1436
      fn get_name(&self) -> &String {
1✔
1437
        &self.name
1✔
1438
      }
1✔
1439

1440
      fn get_k8s_obj(&self) -> &Option<String> {
×
1441
        &None
×
1442
      }
×
1443
    }
1444

1445
    terminal
1✔
1446
      .draw(|f| {
1✔
1447
        let size = f.area();
1✔
1448
        let mut resource: StatefulTable<RenderTest> = StatefulTable::new();
1✔
1449
        resource.set_items(vec![RenderTest {
1✔
1450
          name: "test".into(),
1✔
1451
        }]);
1✔
1452
        resource.filter = "pod".into();
1✔
1453
        resource.filter_active = true;
1✔
1454
        draw_resource_block(
1✔
1455
          f,
1✔
1456
          size,
1✔
1457
          ResourceTableProps {
1✔
1458
            title: "Test".into(),
1✔
1459
            inline_help: help_bold_line("describe <d> | back <Esc>", false),
1✔
1460
            resource: &mut resource,
1✔
1461
            table_headers: vec!["Name"],
1✔
1462
            column_widths: vec![Constraint::Percentage(100)],
1✔
1463
          },
1✔
UNCOV
1464
          |c| Row::new(vec![Cell::from(c.name.to_owned())]).style(style_primary(false)),
×
1465
          false,
1466
          false,
1467
        );
1468
      })
1✔
1469
      .unwrap();
1✔
1470

1471
    let first_line = (0..terminal.backend().buffer().area.width)
1✔
1472
      .map(|col| terminal.backend().buffer()[(col, 0)].symbol())
100✔
1473
      .collect::<String>();
1✔
1474
    assert!(first_line.contains("[pod]"));
1✔
1475
    assert!(first_line.contains("clear <Esc>"));
1✔
1476
    assert!(!first_line.contains("describe <d>"));
1✔
1477
    assert!(!first_line.contains("back <Esc>"));
1✔
1478
  }
1✔
1479

1480
  #[test]
1481
  fn test_title_with_ns() {
1✔
1482
    assert_eq!(title_with_ns("Title", "hello", 3), "Title (ns: hello) [3]");
1✔
1483
  }
1✔
1484

1485
  #[test]
1486
  fn test_get_cluster_wide_resource_title() {
1✔
1487
    assert_eq!(
1✔
1488
      get_cluster_wide_resource_title("Cluster Resource", 3, ""),
1✔
1489
      " Cluster Resource [3] "
1490
    );
1491
    assert_eq!(
1✔
1492
      get_cluster_wide_resource_title("Nodes", 10, "-> hello"),
1✔
1493
      " Nodes [10] -> hello"
1494
    );
1495
  }
1✔
1496

1497
  #[test]
1498
  fn test_build_resource_help_line() {
1✔
1499
    // Case 1: Empty inline_help, empty filter, filter_active=false
1500
    // -> line text should contain the inactive "filter <key>" action hint
1501
    let line = build_resource_help_line(Line::default(), "", false, false);
1✔
1502
    let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1✔
1503
    let expected_filter_hint = action_hint("filter", DEFAULT_KEYBINDING.filter.key);
1✔
1504
    assert!(
1✔
1505
      text.contains(&expected_filter_hint),
1✔
1506
      "Case 1: expected '{text}' to contain '{expected_filter_hint}'"
1507
    );
1508

1509
    // Case 2: Non-empty inline_help, empty filter, filter_active=false
1510
    // -> line text should contain the inline help hint after " | "
1511
    let line2 = build_resource_help_line(help_bold_line("-> yaml <y>", false), "", false, false);
1✔
1512
    let text2: String = line2.spans.iter().map(|s| s.content.as_ref()).collect();
3✔
1513
    assert!(
1✔
1514
      text2.contains("-> yaml <y>"),
1✔
1515
      "Case 2: expected '{text2}' to contain '-> yaml <y>'"
1516
    );
1517

1518
    // Case 3: inline_help starting with the containers prefix
1519
    // -> line text should start with the containers hint
1520
    let containers_prefix_str = format!(
1✔
1521
      "{} | ",
1522
      action_hint("containers", DEFAULT_KEYBINDING.submit.key)
1✔
1523
    );
1524
    let line3 = build_resource_help_line(
1✔
1525
      help_bold_line(containers_prefix_str.as_str(), false),
1✔
1526
      "",
1✔
1527
      false,
1528
      false,
1529
    );
1530
    let text3: String = line3.spans.iter().map(|s| s.content.as_ref()).collect();
2✔
1531
    let containers_hint = action_hint("containers", DEFAULT_KEYBINDING.submit.key);
1✔
1532
    assert!(
1✔
1533
      text3.starts_with(&containers_hint),
1✔
1534
      "Case 3: expected '{text3}' to start with '{containers_hint}'"
1535
    );
1536

1537
    // Case 4: Empty inline_help, filter="foo", filter_active=false
1538
    // -> line text should contain "[foo]"
1539
    let line4 = build_resource_help_line(Line::default(), "foo", false, false);
1✔
1540
    let text4: String = line4.spans.iter().map(|s| s.content.as_ref()).collect();
2✔
1541
    assert!(
1✔
1542
      text4.contains("[foo]"),
1✔
1543
      "Case 4: expected '{text4}' to contain '[foo]'"
1544
    );
1545
  }
1✔
1546
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc