도그푸딩 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>
78 lines
2.8 KiB
Rust
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)
|
|
}
|