magi-code 0.82.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::{PICKER_MODAL, draw_modal_panel_with_hotkeys, modal_text_area};
use crate::tui::{
    input::THEME_VARIANT_HELP,
    modal_container::modal_tab,
    state::{MissionControlState, ThemeSaveState},
};
use ratatui::{
    Frame,
    layout::Rect,
    text::{Line, Span},
    widgets::{List, ListItem, Paragraph},
};
use unicode_width::UnicodeWidthStr;

use unicode_segmentation::UnicodeSegmentation;

const THEME_PICKER_FOOTER_ROWS: u16 = 2;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ThemePickerGeometry {
    tabs: Rect,
    query: Rect,
    list: Rect,
    footer: Rect,
}

fn theme_picker_geometry(area: Rect) -> Option<ThemePickerGeometry> {
    let inner = modal_text_area(area, "Themes", PICKER_MODAL)?;
    let tabs = Rect::new(inner.x, inner.y, inner.width, inner.height.min(1));
    let query = Rect::new(
        inner.x,
        tabs.bottom(),
        inner.width,
        inner.bottom().saturating_sub(tabs.bottom()).min(1),
    );
    let list_y = query.bottom();
    let footer_height = inner
        .bottom()
        .saturating_sub(list_y)
        .min(THEME_PICKER_FOOTER_ROWS);
    let footer_y = inner.bottom().saturating_sub(footer_height);
    let list = Rect::new(
        inner.x,
        list_y,
        inner.width,
        footer_y.saturating_sub(list_y),
    );
    let footer = Rect::new(inner.x, footer_y, inner.width, footer_height);
    Some(ThemePickerGeometry {
        tabs,
        query,
        list,
        footer,
    })
}

pub(crate) fn theme_picker_query_visible(area: Rect) -> bool {
    theme_picker_geometry(area)
        .is_some_and(|geometry| geometry.query.width > 0 && geometry.query.height > 0)
}

pub(crate) fn theme_picker_list_area(area: Rect) -> Option<Rect> {
    theme_picker_geometry(area)
        .and_then(|geometry| (geometry.list.height > 0).then_some(geometry.list))
}

fn query_window(query: &str, cursor: usize, width: usize) -> (&str, usize) {
    let cursor = cursor.min(query.len());
    let mut start = cursor;
    let mut used = 0;
    for (index, grapheme) in query[..cursor].grapheme_indices(true).rev() {
        let next = UnicodeWidthStr::width(grapheme);
        if used + next > width.saturating_sub(1) {
            break;
        }
        used += next;
        start = index;
    }
    (&query[start..], used)
}

pub(in crate::tui::render) fn draw_theme_picker(
    frame: &mut Frame<'_>,
    area: Rect,
    state: &MissionControlState,
) {
    let Some(picker) = &state.modals.theme_picker else {
        return;
    };
    let Some(geometry) = theme_picker_geometry(area) else {
        return;
    };
    let pending = matches!(picker.save_state, ThemeSaveState::Pending);
    let mut hotkeys = format!("{THEME_VARIANT_HELP} • [↑/↓] preview • [Enter] save");
    if hotkeys.width() > usize::from(geometry.footer.width) {
        hotkeys = format!("{THEME_VARIANT_HELP} • [↑/↓] • [Enter] save");
    }
    let Some(inner) = draw_modal_panel_with_hotkeys(
        frame,
        area,
        "Themes",
        PICKER_MODAL,
        state.theme,
        if pending { "Saving theme…" } else { &hotkeys },
        if pending { None } else { Some("[Esc] close") },
    ) else {
        return;
    };
    if inner.width == 0 || inner.height == 0 {
        return;
    }
    frame.render_widget(
        Paragraph::new(Line::from(vec![
            modal_tab(
                "Dark",
                picker.active_variant == opaline::ThemeVariant::Dark,
                state.theme,
            ),
            modal_tab(
                "Light",
                picker.active_variant == opaline::ThemeVariant::Light,
                state.theme,
            ),
        ])),
        geometry.tabs,
    );
    let query = &picker.query;
    let query_width = geometry.query.width.saturating_sub(8) as usize;
    let (visible_query, cursor) = query_window(query, picker.query_cursor, query_width);
    frame.render_widget(
        Paragraph::new(Line::from(vec![
            Span::raw("Search: "),
            Span::raw(visible_query),
        ])),
        geometry.query,
    );
    let content = geometry.list;
    if (picker.loading || picker.error.is_some()) && picker.entries.is_empty() {
        let message = if picker.loading {
            "Loading themes…".to_string()
        } else {
            format!(
                "Theme catalog error: {}",
                picker.error.as_deref().unwrap_or("unknown error")
            )
        };
        frame.render_widget(Paragraph::new(message), content);
    } else if picker.filtered_indices.is_empty() {
        let message = match picker.active_variant {
            opaline::ThemeVariant::Dark => "No matching dark themes",
            opaline::ThemeVariant::Light => "No matching light themes",
        };
        frame.render_widget(Paragraph::new(message), content);
    } else {
        let items = picker
            .filtered_indices
            .iter()
            .skip(state.scroll_offset(&state.scroll_views.theme_modal))
            .take(content.height as usize)
            .filter_map(|index| picker.entries.get(*index))
            .map(|entry| {
                let marker = if entry.id == picker.preview_theme_id {
                    "› "
                } else {
                    "  "
                };
                ListItem::new(format!("{marker}{} — {}", entry.id, entry.display_name))
            })
            .collect::<Vec<_>>();
        debug_assert_eq!(
            items.len(),
            picker
                .filtered_indices
                .iter()
                .skip(state.scroll_offset(&state.scroll_views.theme_modal))
                .take(content.height as usize)
                .count()
        );
        frame.render_widget(List::new(items), content);
    }
    let mut footer_y = geometry.footer.y;
    if let Some(notice) = &picker.notice
        && footer_y < geometry.footer.bottom()
    {
        frame.render_widget(
            Paragraph::new(notice.clone()),
            Rect::new(geometry.footer.x, footer_y, geometry.footer.width, 1),
        );
        footer_y = footer_y.saturating_add(1);
    }
    if matches!(picker.save_state, ThemeSaveState::Pending) && footer_y < geometry.footer.bottom() {
        frame.render_widget(
            Paragraph::new("Saving theme…"),
            Rect::new(geometry.footer.x, footer_y, geometry.footer.width, 1),
        );
    } else if let ThemeSaveState::Failed(error) = &picker.save_state
        && footer_y < geometry.footer.bottom()
    {
        frame.render_widget(
            Paragraph::new(format!("Save failed: {error}")),
            Rect::new(geometry.footer.x, footer_y, geometry.footer.width, 1),
        );
    }
    if geometry.query.width > 8 && geometry.query.height > 0 {
        let cursor_x = geometry
            .query
            .x
            .saturating_add(8)
            .saturating_add(cursor.min(query_width) as u16);
        if cursor_x < geometry.query.right() {
            frame.set_cursor_position((cursor_x, geometry.query.y));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{appearance::ThemeCatalogEntry, tui::state::MissionControlState};
    use ratatui::{Terminal, backend::TestBackend};

    fn state_with_entries(
        save_state: ThemeSaveState,
        notice: Option<String>,
    ) -> MissionControlState {
        let mut state = MissionControlState::default();
        state.open_theme_picker("matrix-green".into(), 1, false, None, Vec::new(), None);
        state.set_theme_picker_catalog(
            vec![
                ThemeCatalogEntry {
                    id: "theme-one".into(),
                    display_name: "Theme One".into(),
                    variant: opaline::ThemeVariant::Dark,
                },
                ThemeCatalogEntry {
                    id: "theme-two".into(),
                    display_name: "Theme Two".into(),
                    variant: opaline::ThemeVariant::Dark,
                },
                ThemeCatalogEntry {
                    id: "day-theme".into(),
                    display_name: "Day Theme".into(),
                    variant: opaline::ThemeVariant::Light,
                },
            ],
            notice,
            10,
        );
        if matches!(save_state, ThemeSaveState::Pending) {
            state.set_theme_picker_save_pending();
        } else if let ThemeSaveState::Failed(error) = save_state {
            state.set_theme_picker_save_failed(error);
        }
        state
    }

    fn rendered_text(state: &MissionControlState, width: u16, height: u16) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| draw_theme_picker(frame, Rect::new(0, 0, width, height), state))
            .unwrap();
        terminal
            .backend()
            .buffer()
            .content()
            .iter()
            .map(|cell| cell.symbol())
            .collect()
    }

    #[test]
    fn theme_picker_rows_remain_visible_with_notice_and_pending_footer() {
        let state = state_with_entries(ThemeSaveState::Pending, Some("catalog warning".into()));
        let text = rendered_text(&state, 80, 20);
        assert!(text.contains("theme-one"));
        assert!(text.contains("catalog warning"));
        assert!(text.contains("Saving theme"));
        assert!(text.contains("[Dark]"));
        assert!(!text.contains("[Esc]"));
        assert!(!text.contains(THEME_VARIANT_HELP));
    }

    #[test]
    fn theme_picker_failed_footer_does_not_cover_rows() {
        let state = state_with_entries(ThemeSaveState::Failed("disk full".into()), None);
        let text = rendered_text(&state, 80, 20);
        assert!(text.contains("theme-two"));
        assert!(text.contains("Save failed"));
    }

    #[test]
    fn theme_picker_tabs_show_only_their_category_and_name_empty_results() {
        let mut state = state_with_entries(ThemeSaveState::Idle, None);
        for width in [80, 120] {
            let text = rendered_text(&state, width, 24);
            assert!(text.contains("[Dark] Light "));
            assert!(text.contains("theme-one"));
            assert!(!text.contains("day-theme"));
            assert!(text.contains(THEME_VARIANT_HELP));
            assert!(text.contains("[Enter] save"));
            assert!(text.contains("[Esc] close"));
        }

        state.theme_picker_switch_variant();
        let text = rendered_text(&state, 120, 24);
        assert!(text.contains(" Dark [Light]"));
        assert!(text.contains("day-theme"));
        assert!(!text.contains("theme-one"));
        state.theme_picker_insert_char('!');
        assert!(rendered_text(&state, 120, 24).contains("No matching light themes"));
        state.theme_picker_switch_variant();
        assert!(rendered_text(&state, 120, 24).contains("No matching dark themes"));
    }

    #[test]
    fn theme_picker_tiny_area_and_wide_query_cursor_are_safe() {
        let mut state = state_with_entries(ThemeSaveState::Idle, None);
        if let Some(picker) = state.modals.theme_picker.as_mut() {
            picker.query = "界".repeat(100);
            picker.query_cursor = picker.query.len();
        }
        for (width, height) in [(5, 3), (12, 5), (20, 8), (40, 12), (80, 24)] {
            let area = Rect::new(0, 0, width, height);
            let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
            terminal
                .draw(|frame| draw_theme_picker(frame, area, &state))
                .unwrap();
            if let Some(geometry) = theme_picker_geometry(area) {
                let inner = modal_text_area(area, "Themes", PICKER_MODAL).unwrap();
                for row_area in [
                    geometry.tabs,
                    geometry.query,
                    geometry.list,
                    geometry.footer,
                ] {
                    assert_eq!(row_area.intersection(inner), row_area);
                }
                assert_eq!(geometry.tabs.bottom(), geometry.query.y);
                assert_eq!(geometry.query.bottom(), geometry.list.y);
                assert_eq!(geometry.list.bottom(), geometry.footer.y);
                let viewport = crate::tui::viewports::key_viewports(area, &state);
                assert_eq!(
                    viewport.theme_picker.map(|viewport| viewport.visible_rows),
                    (geometry.list.height > 0).then_some(geometry.list.height)
                );
                assert_eq!(
                    viewport.theme_query_visible,
                    geometry.query.width > 0 && geometry.query.height > 0
                );
                if geometry.query.width > 8 && geometry.query.height > 0 {
                    let cursor =
                        ratatui::backend::Backend::get_cursor_position(terminal.backend_mut())
                            .unwrap();
                    assert!(geometry.query.contains(cursor));
                    let query_width = geometry.query.width - 8;
                    let visible_wide_chars = query_width.saturating_sub(1) / 2;
                    assert_eq!(cursor.x, geometry.query.x + 8 + visible_wide_chars * 2);
                }
            }
        }
    }
}