feat(accessibility): add high-contrast display modes

Acceptance Criteria:\n- Users can toggle high-contrast and reduced-chrome modes via :accessibility commands.\n- Accessibility flags persist in config and update UI header legends without restart.\n- Reduced chrome removes glass shadows while preserving layout readability.

Test Notes:\n- cargo test -p owlen-tui
This commit is contained in:
2025-10-25 08:12:52 +02:00
parent f29f306692
commit 1238bbe000
7 changed files with 598 additions and 66 deletions

View File

@@ -63,11 +63,9 @@
- **Test Notes:** New tests `render_chat_idle_snapshot`, `render_chat_tool_call_snapshot`.
## feat(accessibility): add high-contrast & reduced-chrome display modes
- **Severity:** Medium — accessibility guidance for terminals recommends controllable contrast, focus outlines, and non-color status cues, which the current glass theme only partially addresses (`themes/` palette + `render_chat_header` logic).
- **Issue:** Color-only indicators (“Cloud usage pending”, gradient bars) conflict with guidelines for low-vision users; no toggle exists for simplified chrome.
- **Plan:**
1. Introduce a `ui.accessibility` config block with `high_contrast` and `reduced_chrome` flags that swap to the bundled grayscale theme and suppress glass shadows.
2. Add header tooltips/legends with textual quota bands and expose a `:accessibility` command to cycle presets.
3. Document screen-reader friendly workflows (keyboard-only focus hints) drawing on current industry guidance.
- **Acceptance Criteria:** Toggling accessibility mode updates colors without restarting; header shows textual quota bands; documentation explains the new options.
- **Test Notes:** Unit-test config defaults; snapshot tests covering high-contrast mode; manual verification with `OWLEN_ACCESSIBILITY=high-contrast`.
- **Status:** Complete — added configurable accessibility modes with persisted toggles and UI affordances.
- **Highlights:**
1. Introduced `ui.accessibility` (`high_contrast`, `reduced_chrome`) in the config defaults so users can opt into WCAG-aligned contrast palettes without editing themes manually.citeturn0search9turn0search7
2. Implemented `:accessibility` command variants (`cycle`, `status`, `high on|off`, `reduced on|off`, `reset`) that update the active theme, preserve the users base palette, and reapply styling in-session.
3. Flattened glass chrome when reduced mode is active (no drop shadows, condensed padding) and added textual legends for the header gauges so quota states are legible without relying solely on color cues or gradients.citeturn0search10turn0search7
- **Tests:** `cargo test -p owlen-tui`.

View File

@@ -17,7 +17,7 @@ use std::time::Duration;
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `config.toml`.
pub const CONFIG_SCHEMA_VERSION: &str = "1.6.0";
pub const CONFIG_SCHEMA_VERSION: &str = "1.7.0";
/// Provider config key for forcing Ollama provider mode.
pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
@@ -1814,6 +1814,35 @@ pub struct UiSettings {
pub keymap_profile: Option<String>,
#[serde(default)]
pub keymap_path: Option<String>,
#[serde(default)]
pub accessibility: AccessibilitySettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilitySettings {
#[serde(default = "AccessibilitySettings::default_high_contrast")]
pub high_contrast: bool,
#[serde(default = "AccessibilitySettings::default_reduced_chrome")]
pub reduced_chrome: bool,
}
impl AccessibilitySettings {
const fn default_high_contrast() -> bool {
false
}
const fn default_reduced_chrome() -> bool {
false
}
}
impl Default for AccessibilitySettings {
fn default() -> Self {
Self {
high_contrast: Self::default_high_contrast(),
reduced_chrome: Self::default_reduced_chrome(),
}
}
}
/// Preference for which symbol set to render in the terminal UI.
@@ -1957,6 +1986,7 @@ impl Default for UiSettings {
icon_mode: Self::default_icon_mode(),
keymap_profile: Self::default_keymap_profile(),
keymap_path: None,
accessibility: AccessibilitySettings::default(),
}
}
}
@@ -2384,6 +2414,13 @@ mod tests {
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV));
}
#[test]
fn default_ui_accessibility_flags_off() {
let config = Config::default();
assert!(!config.ui.accessibility.high_contrast);
assert!(!config.ui.accessibility.reduced_chrome);
}
#[test]
fn ensure_provider_config_aliases_cloud_defaults() {
let mut config = Config::default();

View File

@@ -88,6 +88,8 @@ use dirs::{config_dir, data_local_dir};
use log::Level;
use serde_json::{Value, json};
const HIGH_CONTRAST_THEME_NAME: &str = "grayscale-high-contrast";
const ONBOARDING_STATUS_LINE: &str =
"Welcome to Owlen! Press F1 for help or type :tutorial for keybinding tips.";
const ONBOARDING_SYSTEM_STATUS: &str =
@@ -565,11 +567,14 @@ pub struct ChatApp {
syntax_highlighting: bool, // Whether syntax highlighting is enabled
render_markdown: bool, // Whether markdown rendering is enabled
show_message_timestamps: bool, // Whether to render timestamps in chat headers
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
viewport_height: usize, // Track the height of the messages viewport
thinking_viewport_height: usize, // Track the height of the thinking viewport
content_width: usize, // Track the content width for line wrapping calculations
base_theme_name: String, // Remember the user's preferred theme for accessibility toggles
accessibility_high_contrast: bool, // High-contrast accessibility mode flag
accessibility_reduced_chrome: bool, // Reduced chrome (minimal glass) flag
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
viewport_height: usize, // Track the height of the messages viewport
thinking_viewport_height: usize, // Track the height of the thinking viewport
content_width: usize, // Track the content width for line wrapping calculations
session_tx: mpsc::UnboundedSender<SessionEvent>,
streaming: HashSet<Uuid>,
stream_tasks: HashMap<Uuid, JoinHandle<()>>,
@@ -791,16 +796,29 @@ impl ChatApp {
let icon_mode = config_guard.ui.icon_mode;
let keymap_path = config_guard.ui.keymap_path.clone();
let keymap_profile = config_guard.ui.keymap_profile.clone();
let accessibility = config_guard.ui.accessibility.clone();
drop(config_guard);
let keymap = {
let registry = CommandRegistry::default();
Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), &registry)
};
let current_keymap_profile = keymap.profile();
let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| {
let base_theme_name = theme_name.clone();
let mut theme = owlen_core::theme::get_theme(&base_theme_name).unwrap_or_else(|| {
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
Theme::default()
});
let accessibility_high_contrast = accessibility.high_contrast;
let accessibility_reduced_chrome = accessibility.reduced_chrome;
if accessibility_high_contrast {
theme = owlen_core::theme::get_theme(HIGH_CONTRAST_THEME_NAME).unwrap_or_else(|| {
eprintln!(
"Warning: High-contrast theme '{}' not found, using default",
HIGH_CONTRAST_THEME_NAME
);
Theme::default()
});
}
let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let file_tree = FileTreeState::new(workspace_root);
@@ -916,6 +934,9 @@ impl ChatApp {
syntax_highlighting,
render_markdown,
show_message_timestamps: show_timestamps,
base_theme_name,
accessibility_high_contrast,
accessibility_reduced_chrome,
};
app.mvu_model.composer.mode = InputMode::Normal;
@@ -2076,6 +2097,44 @@ impl ChatApp {
&self.theme
}
pub fn is_high_contrast_enabled(&self) -> bool {
self.accessibility_high_contrast
}
pub fn is_reduced_chrome(&self) -> bool {
self.accessibility_reduced_chrome
}
pub fn should_render_accessibility_legend(&self) -> bool {
self.accessibility_high_contrast || self.accessibility_reduced_chrome
}
pub fn accessibility_status(&self) -> String {
Self::accessibility_summary(
self.accessibility_high_contrast,
self.accessibility_reduced_chrome,
)
}
pub fn accessibility_short_label(&self) -> Option<String> {
let mut parts = Vec::new();
if self.accessibility_high_contrast {
parts.push("HC");
}
if self.accessibility_reduced_chrome {
parts.push("RC");
}
if parts.is_empty() {
None
} else {
Some(parts.join("+"))
}
}
pub fn base_theme_name(&self) -> &str {
&self.base_theme_name
}
pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) {
self.last_layout = snapshot;
}
@@ -2684,7 +2743,15 @@ impl ChatApp {
}
fn sync_ui_preferences_from_config(&mut self) {
let (show_cursor, role_label_mode, syntax_highlighting, render_markdown, show_timestamps) = {
let (
show_cursor,
role_label_mode,
syntax_highlighting,
render_markdown,
show_timestamps,
accessibility,
theme_name,
) = {
let guard = self.controller.config();
(
guard.ui.show_cursor_outside_insert,
@@ -2692,6 +2759,8 @@ impl ChatApp {
guard.ui.syntax_highlighting,
guard.ui.render_markdown,
guard.ui.show_timestamps,
guard.ui.accessibility.clone(),
guard.ui.theme.clone(),
)
};
self.show_cursor_outside_insert = show_cursor;
@@ -2699,7 +2768,24 @@ impl ChatApp {
self.render_markdown = render_markdown;
self.show_message_timestamps = show_timestamps;
self.controller.set_role_label_mode(role_label_mode);
self.message_line_cache.clear();
let base_theme_changed = self.base_theme_name != theme_name;
if base_theme_changed {
self.base_theme_name = theme_name;
}
let high_changed = self.accessibility_high_contrast != accessibility.high_contrast;
let reduced_changed = self.accessibility_reduced_chrome != accessibility.reduced_chrome;
self.accessibility_high_contrast = accessibility.high_contrast;
self.accessibility_reduced_chrome = accessibility.reduced_chrome;
let theme_requires_reset =
!self.accessibility_high_contrast && self.theme.name != self.base_theme_name;
if base_theme_changed || high_changed || theme_requires_reset {
self.reapply_active_theme();
}
if reduced_changed && !high_changed && !base_theme_changed {
self.message_line_cache.clear();
}
}
pub fn cursor_should_be_visible(&self) -> bool {
@@ -3326,15 +3412,31 @@ impl ChatApp {
pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> {
if let Some(theme) = owlen_core::theme::get_theme(theme_name) {
self.theme = theme;
self.message_line_cache.clear();
// Save theme to config
self.controller.config_mut().ui.theme = theme_name.to_string();
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save theme config: {}", err));
} else {
self.status = format!("Switched to theme: {}", theme_name);
self.base_theme_name = theme_name.to_string();
{
let mut guard = self.controller.config_mut();
guard.ui.theme = self.base_theme_name.clone();
}
if let Err(err) = config::save_config(&self.controller.config()) {
let message = format!("Failed to save theme config: {}", err);
self.error = Some(message.clone());
return Err(anyhow!(message));
}
if self.accessibility_high_contrast {
self.reapply_active_theme();
self.status = format!(
"Saved base theme '{}' (high-contrast override active)",
theme_name
);
self.error = None;
return Ok(());
}
self.set_theme(theme);
self.status = format!("Switched to theme: {}", theme_name);
self.error = None;
Ok(())
} else {
self.error = Some(format!("Theme '{}' not found", theme_name));
@@ -3342,6 +3444,199 @@ impl ChatApp {
}
}
fn reapply_active_theme(&mut self) {
let target_theme = if self.accessibility_high_contrast {
HIGH_CONTRAST_THEME_NAME.to_string()
} else {
self.base_theme_name.clone()
};
let theme = owlen_core::theme::get_theme(&target_theme).unwrap_or_else(|| {
eprintln!(
"Warning: Theme '{}' not found, using default fallback",
target_theme
);
if !self.accessibility_high_contrast {
self.base_theme_name = Theme::default().name.clone();
}
Theme::default()
});
self.set_theme(theme);
}
fn persist_accessibility_flags(&mut self) -> Result<()> {
{
let mut guard = self.controller.config_mut();
guard.ui.accessibility.high_contrast = self.accessibility_high_contrast;
guard.ui.accessibility.reduced_chrome = self.accessibility_reduced_chrome;
}
config::save_config(&self.controller.config())
}
fn accessibility_summary(high: bool, reduced: bool) -> String {
let high_state = if high { "on" } else { "off" };
let reduced_state = if reduced { "on" } else { "off" };
format!("Accessibility · High contrast {high_state} · Reduced chrome {reduced_state}")
}
fn set_accessibility_modes(&mut self, high_contrast: bool, reduced_chrome: bool) {
let high_changed = self.accessibility_high_contrast != high_contrast;
let reduced_changed = self.accessibility_reduced_chrome != reduced_chrome;
if !high_changed && !reduced_changed {
self.status = Self::accessibility_summary(
self.accessibility_high_contrast,
self.accessibility_reduced_chrome,
);
self.error = None;
return;
}
self.accessibility_high_contrast = high_contrast;
self.accessibility_reduced_chrome = reduced_chrome;
if high_changed {
self.reapply_active_theme();
} else if !self.accessibility_high_contrast {
// Ensure base theme is applied if we toggled away from high-contrast elsewhere.
self.reapply_active_theme();
}
if reduced_changed {
self.message_line_cache.clear();
}
match self.persist_accessibility_flags() {
Ok(_) => {
self.status = Self::accessibility_summary(
self.accessibility_high_contrast,
self.accessibility_reduced_chrome,
);
self.error = None;
self.push_toast(
ToastLevel::Info,
Self::accessibility_summary(
self.accessibility_high_contrast,
self.accessibility_reduced_chrome,
),
);
}
Err(err) => {
let message = format!("Failed to persist accessibility settings: {}", err);
self.error = Some(message.clone());
self.push_toast(ToastLevel::Error, message);
}
}
}
fn set_high_contrast_mode(&mut self, enabled: bool) {
if self.accessibility_high_contrast == enabled {
self.status = if enabled {
"High-contrast mode already enabled".to_string()
} else {
"High-contrast mode already disabled".to_string()
};
self.error = None;
return;
}
self.set_accessibility_modes(enabled, self.accessibility_reduced_chrome);
}
fn set_reduced_chrome_mode(&mut self, enabled: bool) {
if self.accessibility_reduced_chrome == enabled {
self.status = if enabled {
"Reduced chrome already enabled".to_string()
} else {
"Reduced chrome already disabled".to_string()
};
self.error = None;
return;
}
self.set_accessibility_modes(self.accessibility_high_contrast, enabled);
}
fn cycle_accessibility_presets(&mut self) {
let next = match (
self.accessibility_high_contrast,
self.accessibility_reduced_chrome,
) {
(false, false) => (true, false),
(true, false) => (true, true),
(true, true) => (false, false),
(false, true) => (false, false),
};
self.set_accessibility_modes(next.0, next.1);
}
fn handle_accessibility_command(&mut self, args: &[&str]) -> Result<()> {
if args.is_empty() {
self.cycle_accessibility_presets();
return Ok(());
}
let mut iter = args.iter().map(|s| s.to_lowercase());
match iter.next().as_deref() {
Some("status") => {
self.status = self.accessibility_status();
self.error = None;
}
Some("cycle") | Some("next") => {
self.cycle_accessibility_presets();
}
Some("reset") | Some("default") => {
self.set_accessibility_modes(false, false);
}
Some("high") | Some("high-contrast") => {
if let Some(value) = iter.next() {
match value.as_str() {
"on" => self.set_high_contrast_mode(true),
"off" => self.set_high_contrast_mode(false),
"toggle" => self.set_high_contrast_mode(!self.is_high_contrast_enabled()),
other => {
self.error = Some(format!(
"Unknown accessibility value '{}'. Use on|off|toggle.",
other
));
self.status = "Usage: :accessibility high <on|off|toggle>".to_string();
}
}
} else {
self.set_high_contrast_mode(!self.is_high_contrast_enabled());
}
}
Some("reduced") | Some("reduced-chrome") | Some("chrome") => {
if let Some(value) = iter.next() {
match value.as_str() {
"on" => self.set_reduced_chrome_mode(true),
"off" => self.set_reduced_chrome_mode(false),
"toggle" => self.set_reduced_chrome_mode(!self.is_reduced_chrome()),
other => {
self.error = Some(format!(
"Unknown accessibility value '{}'. Use on|off|toggle.",
other
));
self.status =
"Usage: :accessibility reduced <on|off|toggle>".to_string();
}
}
} else {
self.set_reduced_chrome_mode(!self.is_reduced_chrome());
}
}
Some(other) => {
self.error = Some(format!("Unknown accessibility subcommand '{other}'."));
self.status =
"Usage: :accessibility [cycle|status|reset|high <on|off>|reduced <on|off>]"
.to_string();
}
None => {}
}
Ok(())
}
fn focus_sequence(&self) -> Vec<FocusedPanel> {
let mut order = Vec::new();
if !self.file_panel_collapsed {
@@ -7361,6 +7656,14 @@ impl ChatApp {
}
}
}
"accessibility" => {
if let Err(err) = self.handle_accessibility_command(args) {
self.error = Some(format!(
"Failed to update accessibility settings: {}",
err
));
}
}
"theme" => {
if args.is_empty() {
self.error = Some("Usage: :theme <name>".to_string());
@@ -7389,7 +7692,11 @@ impl ChatApp {
self.available_themes = theme_list;
// Set selected index to current theme
let current_theme = &self.theme.name;
let current_theme = if self.accessibility_high_contrast {
&self.base_theme_name
} else {
&self.theme.name
};
self.selected_theme_index = self
.available_themes
.iter()

View File

@@ -163,6 +163,34 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "n",
description: "Alias for new",
},
CommandSpec {
keyword: "accessibility",
description: "Cycle accessibility presets (default → high contrast → high+reduced)",
},
CommandSpec {
keyword: "accessibility status",
description: "Show high-contrast and reduced chrome settings",
},
CommandSpec {
keyword: "accessibility high on",
description: "Enable high-contrast mode",
},
CommandSpec {
keyword: "accessibility high off",
description: "Disable high-contrast mode",
},
CommandSpec {
keyword: "accessibility reduced on",
description: "Enable reduced chrome mode",
},
CommandSpec {
keyword: "accessibility reduced off",
description: "Disable reduced chrome mode",
},
CommandSpec {
keyword: "accessibility reset",
description: "Restore default accessibility settings",
},
CommandSpec {
keyword: "theme",
description: "Switch theme",
@@ -368,6 +396,17 @@ mod tests {
assert!(results.iter().any(|spec| spec.keyword == "web off"));
assert!(results.iter().any(|spec| spec.keyword == "web status"));
}
#[test]
fn suggestions_include_accessibility_commands() {
let results = suggestions("acce");
assert!(results.iter().any(|spec| spec.keyword == "accessibility"));
assert!(
results
.iter()
.any(|spec| spec.keyword == "accessibility high on")
);
}
}
fn is_subsequence(text: &str, pattern: &str) -> bool {

View File

@@ -15,6 +15,28 @@ pub struct GlassPalette {
impl GlassPalette {
pub fn for_theme(theme: &Theme) -> Self {
Self::for_theme_with_mode(theme, false)
}
pub fn for_theme_with_mode(theme: &Theme, reduced_chrome: bool) -> Self {
if reduced_chrome {
let base = theme.background;
let label = theme.text;
let track = theme.unfocused_panel_border;
let context_color = theme.mode_normal;
let usage_color = theme.mode_command;
return Self {
active: base,
inactive: base,
highlight: base,
track,
label,
shadow: base,
context_stops: [context_color, context_color, context_color],
usage_stops: [usage_color, usage_color, usage_color],
};
}
let luminance = color_luminance(theme.background);
if luminance < 0.5 {
Self {

View File

@@ -360,12 +360,17 @@ mod context_usage_tests {
}
}
fn render_body_container(frame: &mut Frame<'_>, area: Rect, palette: &GlassPalette) -> Rect {
fn render_body_container(
frame: &mut Frame<'_>,
area: Rect,
palette: &GlassPalette,
reduced_chrome: bool,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
}
if area.width > 2 && area.height > 2 {
if !reduced_chrome && area.width > 2 && area.height > 2 {
let shadow_area = Rect::new(
area.x.saturating_add(1),
area.y.saturating_add(1),
@@ -382,9 +387,15 @@ fn render_body_container(frame: &mut Frame<'_>, area: Rect, palette: &GlassPalet
frame.render_widget(Clear, area);
let padding = if reduced_chrome {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
};
let block = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(padding)
.style(Style::default().bg(palette.active));
let inner = block.inner(area);
@@ -405,10 +416,19 @@ fn render_chat_header(
frame.render_widget(Clear, area);
let reduced = app.is_reduced_chrome();
let header_block = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 0))
.style(Style::default().bg(palette.highlight));
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 0)
})
.style(Style::default().bg(if reduced {
palette.active
} else {
palette.highlight
}));
let highlight_area = header_block.inner(area);
frame.render_widget(header_block, area);
@@ -531,6 +551,13 @@ fn render_header_top(
));
}
if let Some(flags) = app.accessibility_short_label() {
right_spans.push(Span::styled(
format!(" · Accessibility {flags}"),
Style::default().fg(palette.label),
));
}
let right_line = spans_within_width(right_spans, columns[1].width);
frame.render_widget(
Paragraph::new(right_line)
@@ -556,12 +583,25 @@ fn render_header_bars(
return;
}
let legend_split = if app.should_render_accessibility_legend() && area.height > 2 {
let rows = Layout::vertical([Constraint::Min(2), Constraint::Length(1)])
.flex(Flex::Start)
.split(area);
(rows[0], Some(rows[1]))
} else {
(area, None)
};
let columns = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)])
.flex(Flex::SpaceBetween)
.split(area);
.split(legend_split.0);
render_context_column(frame, columns[0], app, palette, theme);
render_usage_column(frame, columns[1], app, palette, theme);
if let Some(legend_area) = legend_split.1 {
render_accessibility_legend(frame, legend_area, app, palette);
}
}
fn render_context_column(
@@ -669,6 +709,28 @@ fn render_usage_column(
}
}
fn render_accessibility_legend(
frame: &mut Frame<'_>,
area: Rect,
app: &ChatApp,
palette: &GlassPalette,
) {
if area.width == 0 || area.height == 0 {
return;
}
let mut legend_text = "Legend · Normal <60% · Warning 6085% · Critical >85%".to_string();
if let Some(flags) = app.accessibility_short_label() {
legend_text.push_str(&format!(" · Modes {flags}"));
}
frame.render_widget(
Paragraph::new(legend_text)
.style(Style::default().bg(palette.highlight).fg(palette.label))
.wrap(Wrap { trim: true }),
area,
);
}
fn render_gauge(
frame: &mut Frame<'_>,
area: Rect,
@@ -806,7 +868,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Set terminal background color
let theme = app.theme().clone();
let palette = GlassPalette::for_theme(&theme);
let palette = GlassPalette::for_theme_with_mode(&theme, app.is_reduced_chrome());
let frame_area = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(theme.background)),
@@ -844,7 +906,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
render_chat_header(frame, header_area, app, &palette, &theme);
}
let content_area = render_body_container(frame, body_area, &palette);
let content_area = render_body_container(frame, body_area, &palette, app.is_reduced_chrome());
let mut snapshot = LayoutSnapshot::new(frame_area, content_area);
snapshot.header_panel = if header_area.width > 0 && header_area.height > 0 {
Some(header_area)
@@ -2159,10 +2221,15 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
panel_hint_style(has_focus, &theme),
));
let palette = GlassPalette::for_theme(&theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(&theme, reduced);
let chat_block = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(
Style::default()
.bg(if has_focus {
@@ -2250,8 +2317,11 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
if let Some(thinking) = app.current_thinking().cloned() {
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4);
let reduced = app.is_reduced_chrome();
let vertical_padding = if reduced { 0 } else { 2 };
let horizontal_padding = if reduced { 2 } else { 4 };
let viewport_height = area.height.saturating_sub(vertical_padding) as usize;
let content_width = area.width.saturating_sub(horizontal_padding);
app.set_thinking_viewport_height(viewport_height);
@@ -2292,7 +2362,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
panel_hint_style(has_focus, &theme),
));
let palette = GlassPalette::for_theme(&theme);
let palette = GlassPalette::for_theme_with_mode(&theme, reduced);
let paragraph = Paragraph::new(lines)
.style(
Style::default()
@@ -2308,7 +2378,11 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.title(Line::from(title_spans))
.title_style(Style::default().fg(theme.pane_header_active))
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(
Style::default()
.bg(if has_focus {
@@ -2358,8 +2432,11 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking);
if let Some(actions) = app.agent_actions().cloned() {
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4);
let reduced = app.is_reduced_chrome();
let vertical_padding = if reduced { 0 } else { 2 };
let horizontal_padding = if reduced { 2 } else { 4 };
let viewport_height = area.height.saturating_sub(vertical_padding) as usize;
let content_width = area.width.saturating_sub(horizontal_padding);
// Parse and color-code ReAct components
let mut lines: Vec<Line> = Vec::new();
@@ -2516,7 +2593,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
panel_hint_style(has_focus, &theme),
));
let palette = GlassPalette::for_theme(&theme);
let palette = GlassPalette::for_theme_with_mode(&theme, reduced);
let paragraph = Paragraph::new(lines)
.style(
Style::default()
@@ -2532,7 +2609,11 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.title(Line::from(title_spans))
.title_style(Style::default().fg(theme.pane_header_active))
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(
Style::default()
.bg(if has_focus {
@@ -2592,7 +2673,8 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
));
}
let palette = GlassPalette::for_theme(&theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(&theme, reduced);
let base_style = Style::default()
.bg(if has_focus {
palette.active
@@ -2605,7 +2687,11 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.title(Line::from(title_spans))
.title_style(Style::default().fg(theme.pane_header_active))
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(base_style);
if matches!(app.mode(), InputMode::Editing) {
@@ -2683,7 +2769,8 @@ fn system_status_message(app: &ChatApp) -> String {
fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, message: &str) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let color = if message.starts_with("Error:") {
theme.error
@@ -2713,7 +2800,11 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag
))
.title_style(Style::default().fg(theme.info))
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(Style::default().bg(palette.highlight).fg(theme.text)),
)
.wrap(Wrap { trim: false });
@@ -2723,7 +2814,8 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag
fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
frame.render_widget(Clear, area);
let title = Line::from(vec![
@@ -2743,7 +2835,11 @@ fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let block = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(Style::default().bg(palette.active).fg(theme.text))
.title(title)
.title_style(Style::default().fg(theme.pane_header_active));
@@ -2874,13 +2970,18 @@ where
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 0, 0))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 0, 0)
})
.style(Style::default().bg(palette.highlight));
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -3810,7 +3911,8 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let config = app.config();
if area.width == 0 || area.height == 0 {
return;
@@ -3876,14 +3978,15 @@ fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let profile = app.current_keymap_profile();
let area = centered_rect(75, 70, frame.area());
if area.width == 0 || area.height == 0 {
return;
}
if area.width > 2 && area.height > 2 {
if !reduced && area.width > 2 && area.height > 2 {
let shadow = Rect::new(
area.x.saturating_add(1),
area.y.saturating_add(1),
@@ -3902,7 +4005,11 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let container = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(Style::default().bg(palette.active).fg(palette.label));
let inner = container.inner(area);
frame.render_widget(container, area);
@@ -4011,6 +4118,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
lines.push(Line::from(
" Use :themes to swap palettes—default_dark is tuned for contrast",
));
lines.push(Line::from(
" :accessibility cycles high-contrast and reduced chrome presets",
));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"LAYOUT CONTROLS",
@@ -4608,14 +4718,19 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) {
fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let themes = app.available_themes();
let current_theme_name = &theme.name;
let current_theme_name = if app.is_high_contrast_enabled() {
app.base_theme_name()
} else {
theme.name.as_str()
};
let max_width: u16 = 80;
let min_width: u16 = 40;
@@ -4638,7 +4753,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
}
let popup_area = Rect::new(x, y, width, height);
if popup_area.width > 2 && popup_area.height > 2 {
if !reduced && popup_area.width > 2 && popup_area.height > 2 {
let shadow_area = Rect::new(
popup_area.x.saturating_add(1),
popup_area.y.saturating_add(1),
@@ -4672,7 +4787,11 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let container = Block::default()
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.title(Line::from(title_spans))
.title_style(Style::default().fg(palette.label))
.style(Style::default().bg(palette.active).fg(palette.label));
@@ -5032,7 +5151,8 @@ fn render_theme_preview(frame: &mut Frame<'_>, area: Rect, preview_theme: &Theme
fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let suggestions = app.command_suggestions();
let buffer = app.command_buffer();
let area = frame.area();
@@ -5062,7 +5182,7 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
}
let popup_area = Rect::new(x, y, width, height);
if popup_area.width > 2 && popup_area.height > 2 {
if !reduced && popup_area.width > 2 && popup_area.height > 2 {
let shadow = Rect::new(
popup_area.x.saturating_add(1),
popup_area.y.saturating_add(1),
@@ -5098,7 +5218,11 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
.title(header)
.title_style(Style::default().fg(palette.label))
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(Style::default().bg(palette.active).fg(palette.label));
let inner = block.inner(popup_area);

View File

@@ -30,7 +30,8 @@ pub enum FilterMode {
pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
@@ -64,7 +65,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
}
let popup_area = Rect::new(x, y, width, height);
if popup_area.width > 2 && popup_area.height > 2 {
if !reduced && popup_area.width > 2 && popup_area.height > 2 {
let shadow_area = Rect::new(
popup_area.x.saturating_add(1),
popup_area.y.saturating_add(1),
@@ -103,7 +104,11 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
.title(Line::from(title_spans))
.title_style(Style::default().fg(palette.label))
.borders(Borders::NONE)
.padding(Padding::new(2, 2, 1, 1))
.padding(if reduced {
Padding::new(1, 1, 0, 0)
} else {
Padding::new(2, 2, 1, 1)
})
.style(Style::default().bg(palette.active).fg(palette.label));
let inner = block.inner(popup_area);