Files
kebab/crates/kebab-tui/src/error_popup.rs
altair823 afb65702b6 feat(kebab-tui): p9-fb-14 color theme module — role-based palette
도그푸딩 item 12 — TUI 가 모든 정보 종류에 같은 회색 / 시안 만 쓰던
\"빈약한 색감\" 해소. inline `Style::default().fg(Color::*)` 호출을
single source `theme` 모듈로 격리 + dark / light 두 팔레트 제공.

## 핵심 변경

- **`kebab-tui::theme::{Theme, Role, Palette}`** 신규 (132 라인). 16
  개 Role enum (BorderActive/BorderInactive/Title/Path/ModeLexical/
  ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/
  CitationMarker/Bullet/Body) 을 dark + light 두 팔레트가 exhaustive
  match 로 매핑. 새 Role 추가 시 두 팔레트 모두 갱신해야 컴파일됨.

- **`Theme::from_name(s)`** — 알 수 없는 값 (e.g. \"solarized\") →
  dark fallback. config typo 가 TUI 를 죽이지 않음 (spec 명시).

- **`App.theme: Theme`** 신규 — `App::new` 가 `config.ui.theme` 에서
  resolve. 모든 pane (library/search/ask/inspect/run/error_popup) 이
  `app.theme.style(Role::X)` 로 style 가져옴.

- **`Config.ui.theme: String`** 신규 — `[ui] theme = \"dark\" | \"light\"`
  (default `\"dark\"`). `#[serde(default)]` 로 기존 config 파일 호환.

- **Pane sweep**: search.rs / ask.rs / library.rs / inspect.rs /
  run.rs / error_popup.rs 의 모든 inline `Style::default().fg(Color::*)`
  / `add_modifier(Modifier::DIM/REVERSED)` 호출 제거. 일부 helper
  (`render_filter_overlay`, `header_kv`, `kv`, `push_section_header`,
  `build_doc_lines`, `build_chunk_lines`, `render_input/answer/bottom/
  status/citations`, `render_error_overlay`) 가 `theme: &Theme` 파라
  미터 추가.

## Out of scope

- `T` 키 runtime toggle — mode machine (p9-fb-12) 미진행이라 NORMAL
  모드 정의 불가, config 만으로 결정. 추후 p9-fb-12 후속에서 추가.
- 사용자 정의 `[theme.custom]` 절 — P+ task.
- truecolor → 256-color fallback — terminal 가정.

## 테스트

- 신규 4 개 (theme.rs):
  - `every_role_resolves_in_dark_and_light` — 16 Role 전부 panic 없이
    Style 반환 (exhaustive match runtime 검증)
  - `from_name_recognizes_dark_light_and_falls_back` — 입력 정규화 +
    fallback 정책
  - `default_palette_is_dark` — 기본값 pin
  - `primary_roles_carry_decoration_in_dark` — Title/Selected/Heading/
    Error/Warning/Success 가 bare default 로 회귀 안 함
- 기존 75 개 TUI 테스트 (14 lib + 18 ask + 12 inspect + 10 library +
  17 search + 4 theme) 모두 통과
- `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy -p kebab-tui -p kebab-config --all-targets -- -D warnings`
  clean

## 문서

- README Configuration 절: `[ui]` 섹션 + `theme = \"dark\"|\"light\"`
  안내
- docs/SMOKE.md: config 예시에 `[ui] theme = \"dark\"` 라인 추가
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: planned → in_progress

p9-fb-11 (ask markdown render) 의 `Theme` 의존성 unblock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:09:53 +00:00

78 lines
2.8 KiB
Rust

//! Error popup overlay — rendered on top of any pane when the last
//! facade call returned `Err`. Any key dismisses (handled by the
//! pane's key handler before its own dispatch).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::theme::{Role, Theme};
/// Captured snapshot of an `anyhow::Error` for rendering. We do NOT
/// store the `anyhow::Error` itself (it is `!Sync` in pre-1.0.99
/// versions on some toolchains and would force lifetime gymnastics
/// on `App`); we render the formatted chain at capture time.
#[derive(Clone, Debug)]
pub struct ErrorOverlay {
pub title: String,
/// Each chain link as a separate line, root-cause last.
pub chain: Vec<String>,
}
impl ErrorOverlay {
pub fn from_anyhow(err: &anyhow::Error) -> Self {
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
Self {
title: "error".to_string(),
chain,
}
}
pub fn from_message(title: impl Into<String>, msg: impl Into<String>) -> Self {
Self {
title: title.into(),
chain: vec![msg.into()],
}
}
}
/// Render the popup centred in `area`. Caller is responsible for
/// clearing the underlying region (`Clear` widget); we do that here.
/// `theme` is threaded so the overlay's red borders / dim hint use
/// the same role-style mapping as every other pane (p9-fb-14).
pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay, theme: &Theme) {
let popup_area = centered_rect(area, 60, 50);
f.render_widget(Clear, popup_area);
let mut lines: Vec<Line> = Vec::with_capacity(overlay.chain.len() + 2);
lines.push(Line::from(Span::styled(
format!("{}: {}", overlay.title, overlay.chain.first().map_or("(unknown)", String::as_str)),
theme.style(Role::Error).add_modifier(Modifier::BOLD),
)));
for cause in overlay.chain.iter().skip(1) {
lines.push(Line::from(format!(" caused by: {cause}")));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"press any key to dismiss",
theme.style(Role::Hint),
)));
let block = Block::default()
.title("error")
.borders(Borders::ALL)
.border_style(theme.style(Role::Error));
let para = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
f.render_widget(para, popup_area);
}
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let w = (area.width * percent_x / 100).max(20).min(area.width);
let h = (area.height * percent_y / 100).max(5).min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect::new(x, y, w, h)
}