Skip to content

Commit d30e3c2

Browse files
fix(tui): preserve focus contrast across pickers and themes
Use readable backing for selected labels and derive themed focus backing from accent luminance. Cover focused cells across built-in themes. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 77122e0 commit d30e3c2

8 files changed

Lines changed: 298 additions & 28 deletions

File tree

src/cortex-tui/src/modal/models.rs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! Models are grouped by provider with section headers for easy navigation.
55
66
use cortex_core::style::{
7-
ACCENT, BORDER, CYAN_PRIMARY, SELECTION_BG, SURFACE_0, TEXT, TEXT_DIM, TEXT_MUTED,
7+
BORDER, CYAN_PRIMARY, CortexStyle, SELECTION_BG, SURFACE_0, TEXT, TEXT_DIM, TEXT_MUTED,
88
};
99
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1010
use ratatui::buffer::Buffer;
@@ -327,9 +327,9 @@ impl ModelsModal {
327327
is_selected: bool,
328328
) {
329329
// Determine styles
330-
// Selected rows: the banner green accent on the dark gray bar — never inverted.
330+
// Selected glyphs get a light backing; the rest of the row stays dark gray.
331331
let (bg, fg, prefix_fg) = if is_selected {
332-
(SELECTION_BG, ACCENT, ACCENT)
332+
(SELECTION_BG, TEXT, TEXT)
333333
} else {
334334
(SURFACE_0, TEXT, TEXT_DIM)
335335
};
@@ -343,11 +343,24 @@ impl ModelsModal {
343343

344344
// Selection prefix: ">" for selected, " " for others
345345
let prefix = if is_selected { ">" } else { " " };
346-
buf.set_string(col, y, prefix, Style::default().fg(prefix_fg).bg(bg));
346+
buf.set_string(
347+
col,
348+
y,
349+
prefix,
350+
if is_selected {
351+
CortexStyle::selected()
352+
} else {
353+
Style::default().fg(prefix_fg).bg(bg)
354+
},
355+
);
347356
col += 2;
348357

349358
// Model name — English product name, never the served slug.
350-
let name_style = Style::default().fg(fg).bg(bg);
359+
let name_style = if is_selected {
360+
CortexStyle::selected()
361+
} else {
362+
Style::default().fg(fg).bg(bg)
363+
};
351364
let display = crate::ui::text_utils::model_display_name(&model.name);
352365
let max_name_len = 35.min(width.saturating_sub(30) as usize);
353366
let truncated_name = if display.len() > max_name_len && max_name_len > 3 {
@@ -722,4 +735,20 @@ mod tests {
722735
let _action_bar = modal.build_action_bar();
723736
// ActionBar is created successfully (basic smoke test)
724737
}
738+
#[test]
739+
fn focus_label_and_caret_cells_have_contrasting_backing() {
740+
let model = ModelInfo::new("test", "Test", "Cortex");
741+
let modal = ModelsModal::new(vec![model.clone()], None);
742+
for width in [40, 120] {
743+
let mut buf = Buffer::empty(Rect::new(0, 0, width, 12));
744+
modal.render_model_row(0, 0, width, &mut buf, &model, true);
745+
assert_eq!(buf[(0, 0)].symbol(), ">");
746+
assert_eq!(buf[(2, 0)].symbol(), "T");
747+
for x in [0, 2, 3, 4, 5] {
748+
assert_eq!(buf[(x, 0)].fg, cortex_core::style::ACCENT);
749+
assert_eq!(buf[(x, 0)].bg, TEXT);
750+
}
751+
assert_eq!(buf[(1, 0)].bg, SELECTION_BG);
752+
}
753+
}
725754
}

src/cortex-tui/src/modal/sessions/rendering.rs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use ratatui::layout::Rect;
55
use ratatui::style::{Color, Modifier, Style};
66

77
use cortex_core::style::{
8-
ACCENT, BORDER, CYAN_PRIMARY, SELECTION_BG, SURFACE_0, SURFACE_1, TEXT, TEXT_DIM, TEXT_MUTED,
9-
YELLOW,
8+
BORDER, CYAN_PRIMARY, CortexStyle, SELECTION_BG, SURFACE_0, SURFACE_1, TEXT, TEXT_DIM,
9+
TEXT_MUTED, YELLOW,
1010
};
1111

1212
use super::session_action::SessionAction;
@@ -82,9 +82,9 @@ pub fn render_search_bar(search_query: &str, area: Rect, buf: &mut Buffer) {
8282

8383
/// Render a single session row.
8484
pub fn render_session_row(session: &SessionInfo, is_selected: bool, area: Rect, buf: &mut Buffer) {
85-
// Selected rows: the banner green accent on the dark gray bar — never inverted.
85+
// Selected glyphs get a light backing; the rest of the row stays dark gray.
8686
let (bg, fg, prefix_fg) = if is_selected {
87-
(SELECTION_BG, ACCENT, ACCENT)
87+
(SELECTION_BG, TEXT, TEXT)
8888
} else {
8989
(SURFACE_0, TEXT, TEXT_DIM)
9090
};
@@ -98,11 +98,24 @@ pub fn render_session_row(session: &SessionInfo, is_selected: bool, area: Rect,
9898

9999
// Selection indicator
100100
let prefix = if is_selected { ">" } else { " " };
101-
buf.set_string(col, area.y, prefix, Style::default().fg(prefix_fg).bg(bg));
101+
buf.set_string(
102+
col,
103+
area.y,
104+
prefix,
105+
if is_selected {
106+
CortexStyle::selected()
107+
} else {
108+
Style::default().fg(prefix_fg).bg(bg)
109+
},
110+
);
102111
col += 2;
103112

104113
// Session name (left-aligned)
105-
let name_style = Style::default().fg(fg).bg(bg);
114+
let name_style = if is_selected {
115+
CortexStyle::selected()
116+
} else {
117+
Style::default().fg(fg).bg(bg)
118+
};
106119

107120
// Build metadata: "2h ago 15 msgs Cortex Mini 1"
108121
let time_ago = session.relative_time();
@@ -218,3 +231,31 @@ pub fn render_confirmation(
218231
buf.set_string(warn_x, area.y + 4, warn, warn_style);
219232
}
220233
}
234+
235+
#[cfg(test)]
236+
mod tests {
237+
use super::*;
238+
239+
#[test]
240+
fn focus_label_and_caret_cells_have_contrasting_backing() {
241+
let session = SessionInfo::new(
242+
std::path::PathBuf::from("/test"),
243+
"Test",
244+
"test",
245+
chrono::Utc::now(),
246+
0,
247+
);
248+
for width in [40, 120] {
249+
let area = Rect::new(0, 0, width, 12);
250+
let mut buf = Buffer::empty(area);
251+
render_session_row(&session, true, Rect::new(0, 0, width, 1), &mut buf);
252+
assert_eq!(buf[(0, 0)].symbol(), ">");
253+
assert_eq!(buf[(2, 0)].symbol(), "T");
254+
for x in [0, 2, 3, 4, 5] {
255+
assert_eq!(buf[(x, 0)].fg, cortex_core::style::ACCENT);
256+
assert_eq!(buf[(x, 0)].bg, TEXT);
257+
}
258+
assert_eq!(buf[(1, 0)].bg, SELECTION_BG);
259+
}
260+
}
261+
}

src/cortex-tui/src/ui/colors.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,28 @@ pub struct AdaptiveColors {
105105
pub background: Color,
106106
}
107107

108+
/// Back focused RGB accent glyphs with a contrasting neutral surface.
109+
pub fn focus_background(accent: Color) -> Color {
110+
// ponytail: built-in accents are RGB; resolve terminal palettes if indexed themes ship.
111+
let Color::Rgb(r, g, b) = accent else {
112+
return cortex_core::style::TEXT;
113+
};
114+
let linear = |channel: u8| {
115+
let value = f64::from(channel) / 255.0;
116+
if value <= 0.04045 {
117+
value / 12.92
118+
} else {
119+
((value + 0.055) / 1.055).powf(2.4)
120+
}
121+
};
122+
let luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
123+
if luminance > 0.169 {
124+
Color::Black
125+
} else {
126+
cortex_core::style::TEXT
127+
}
128+
}
129+
108130
impl AdaptiveColors {
109131
/// Create colors by auto-detecting terminal background
110132
pub fn from_terminal() -> Self {
@@ -365,4 +387,19 @@ mod tests {
365387
assert!(themes.contains(&"ocean_dark"));
366388
assert!(themes.contains(&"monokai"));
367389
}
390+
#[test]
391+
fn focus_backing_matches_builtin_accent_luminance() {
392+
for name in AdaptiveColors::available_themes() {
393+
let colors = AdaptiveColors::from_theme_name(name);
394+
let expected = match *name {
395+
"ocean_dark" | "monokai" => Color::Black,
396+
_ => cortex_core::style::TEXT,
397+
};
398+
assert_eq!(
399+
crate::ui::colors::focus_background(colors.accent),
400+
expected,
401+
"{name}"
402+
);
403+
}
404+
}
368405
}

src/cortex-tui/src/views/question_prompt.rs

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@ impl QuestionPromptView<'_> {
158158
// The active tab is the focused selection: banner green on the gray bar,
159159
// never inverted onto the accent. Answered tabs read white.
160160
let (fg, bg) = if is_active {
161-
(colors.accent, cortex_core::style::TEXT)
161+
(
162+
colors.accent,
163+
crate::ui::colors::focus_background(colors.accent),
164+
)
162165
} else if is_hovered {
163166
(colors.text, colors.user_bg)
164167
} else {
@@ -193,7 +196,10 @@ impl QuestionPromptView<'_> {
193196
let is_hovered = self.hovered_tab == Some(self.state.request.questions.len());
194197

195198
let (fg, bg) = if is_active {
196-
(colors.accent, cortex_core::style::TEXT)
199+
(
200+
colors.accent,
201+
crate::ui::colors::focus_background(colors.accent),
202+
)
197203
} else if is_hovered {
198204
(colors.text, colors.user_bg)
199205
} else {
@@ -267,7 +273,10 @@ impl QuestionPromptView<'_> {
267273
// Focused option: banner green on the gray selection bar; a picked option
268274
// stays white — its `✓` carries the green.
269275
let (fg, bg) = if is_selected {
270-
(colors.accent, cortex_core::style::TEXT)
276+
(
277+
colors.accent,
278+
crate::ui::colors::focus_background(colors.accent),
279+
)
271280
} else if is_hovered {
272281
(colors.text, colors.user_bg)
273282
} else {
@@ -330,7 +339,10 @@ impl QuestionPromptView<'_> {
330339
let label = "Type your own answer";
331340

332341
let (fg, bg) = if is_selected {
333-
(colors.accent, cortex_core::style::TEXT)
342+
(
343+
colors.accent,
344+
crate::ui::colors::focus_background(colors.accent),
345+
)
334346
} else if is_hovered {
335347
(colors.text, colors.user_bg)
336348
} else {
@@ -687,4 +699,60 @@ mod tests {
687699
.expect("title");
688700
assert_eq!(buf[(title_x, title_y)].style().fg, Some(TEXT));
689701
}
702+
#[test]
703+
fn focus_tabs_and_options_are_accessible_in_every_builtin_theme() {
704+
for name in AdaptiveColors::available_themes() {
705+
let colors = AdaptiveColors::from_theme_name(name);
706+
let expected_bg = match *name {
707+
"ocean_dark" | "monokai" => Color::Black,
708+
_ => TEXT,
709+
};
710+
for width in [40, 120] {
711+
let mut req = request();
712+
req.questions[0].allow_custom = true;
713+
req.questions[0].question = "Home".into();
714+
req.questions.push(req.questions[0].clone());
715+
let mut state = QuestionState::new(req);
716+
for confirm in [false, true] {
717+
state.on_confirm_tab = confirm;
718+
let view = QuestionPromptView::new(&state).with_colors(colors.clone());
719+
let area = Rect::new(0, 0, width, 12);
720+
let mut buf = Buffer::empty(area);
721+
view.render_tabs(&Rect::new(0, 0, width, 1), &mut buf, &colors);
722+
let label = if confirm {
723+
"Confirm".to_string()
724+
} else {
725+
state.get_header(0)
726+
};
727+
let row: String = (0..width).map(|x| buf[(x, 0)].symbol()).collect();
728+
let start = row.find(&label).expect("active tab label") as u16;
729+
for x in start..start + label.chars().count() as u16 {
730+
assert_eq!(buf[(x, 0)].fg, colors.accent, "{name}: {label}");
731+
assert_eq!(buf[(x, 0)].bg, expected_bg, "{name}: {label}");
732+
}
733+
}
734+
state.on_confirm_tab = false;
735+
for selected in [0, 2] {
736+
state.selected_index[0] = selected;
737+
let view = QuestionPromptView::new(&state).with_colors(colors.clone());
738+
let mut buf = Buffer::empty(Rect::new(0, 0, width, 12));
739+
view.render_question(
740+
&Rect::new(0, 0, width, 1),
741+
&Rect::new(0, 2, width, 10),
742+
&mut buf,
743+
&colors,
744+
);
745+
let focused: Vec<_> = buf
746+
.content
747+
.iter()
748+
.filter(|cell| cell.fg == colors.accent && cell.symbol() != " ")
749+
.collect();
750+
assert!(!focused.is_empty(), "{name}: option {selected}");
751+
for cell in focused {
752+
assert_eq!(cell.bg, expected_bg, "{name}: option {selected}");
753+
}
754+
}
755+
}
756+
}
757+
}
690758
}

src/cortex-tui/src/widgets/form/modal.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ impl<'a> Widget for FormModal<'a> {
9393
let label_style = if is_focused {
9494
Style::default()
9595
.fg(self.colors.accent)
96-
.bg(cortex_core::style::TEXT)
96+
.bg(crate::ui::colors::focus_background(self.colors.accent))
9797
.bold()
9898
} else {
9999
Style::default().fg(self.colors.text)
@@ -238,7 +238,7 @@ impl<'a> Widget for FormModal<'a> {
238238
let submit_style = if self.state.is_submit_focused() {
239239
Style::default()
240240
.fg(self.colors.accent)
241-
.bg(cortex_core::style::TEXT)
241+
.bg(crate::ui::colors::focus_background(self.colors.accent))
242242
.bold()
243243
} else {
244244
Style::default().fg(self.colors.text_dim)

src/cortex-tui/src/widgets/form/tests.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,40 @@ mod tests {
375375
assert_eq!(state.fields[0].value, "AX🎉B");
376376
assert_eq!(state.fields[0].cursor_pos, 2);
377377
}
378+
#[test]
379+
fn focus_field_and_submit_backing_tracks_the_accent() {
380+
use crate::ui::colors::AdaptiveColors;
381+
use crate::widgets::form::colors::FormModalColors;
382+
use ratatui::{buffer::Buffer, style::Color, widgets::Widget};
383+
for name in AdaptiveColors::available_themes() {
384+
let accent = AdaptiveColors::from_theme_name(name).accent;
385+
let expected = match *name {
386+
"ocean_dark" | "monokai" => Color::Black,
387+
_ => cortex_core::style::TEXT,
388+
};
389+
let mut state = FormState::new("Test", "test", vec![FormField::text("name", "Name")]);
390+
for focus in [0, 1] {
391+
state.focus_index = focus;
392+
for (width, height) in [(60, 20), (120, 40)] {
393+
let area = Rect::new(0, 0, width, height);
394+
let mut buf = Buffer::empty(area);
395+
FormModal::new(&state)
396+
.colors(FormModalColors {
397+
accent,
398+
..Default::default()
399+
})
400+
.render(area, &mut buf);
401+
let focused: Vec<_> = buf
402+
.content
403+
.iter()
404+
.filter(|cell| cell.fg == accent && cell.symbol() != " ")
405+
.collect();
406+
assert!(!focused.is_empty(), "{name}: focus {focus}");
407+
for cell in focused {
408+
assert_eq!(cell.bg, expected, "{name}: focus {focus}");
409+
}
410+
}
411+
}
412+
}
413+
}
378414
}

0 commit comments

Comments
 (0)