Skip to main content

j_cli/command/help/
ui.rs

1use super::app::HelpApp;
2use crate::command::chat::render::display_width;
3use ratatui::{
4    Frame,
5    layout::{Constraint, Direction, Layout, Rect},
6    style::{Modifier, Style},
7    text::{Line, Span},
8    widgets::Paragraph,
9};
10
11/// 绘制帮助 TUI 界面
12pub fn draw_help_ui(f: &mut Frame, app: &mut HelpApp) {
13    let size = f.area();
14    let theme = app.theme().clone();
15
16    // 主布局:Tab 栏(1) + 标题栏(3) + 内容区(flex) + 提示栏(1)
17    let chunks = Layout::default()
18        .direction(Direction::Vertical)
19        .constraints([
20            Constraint::Length(1), // Tab Bar
21            Constraint::Length(3), // Title Bar
22            Constraint::Min(1),    // Content
23            Constraint::Length(1), // Hint Bar
24        ])
25        .split(size);
26
27    draw_tab_bar(f, app, chunks[0], &theme);
28    draw_title_bar(f, app, chunks[1], &theme);
29    draw_content(f, app, chunks[2], &theme);
30    draw_hint_bar(f, chunks[3], &theme);
31}
32
33/// 绘制 Tab 栏
34fn draw_tab_bar(
35    f: &mut Frame,
36    app: &HelpApp,
37    area: Rect,
38    theme: &crate::command::chat::theme::Theme,
39) {
40    let mut spans: Vec<Span> = Vec::new();
41    spans.push(Span::styled(" ", Style::default().bg(theme.bg_title)));
42
43    for i in 0..app.tab_count {
44        let num = if i == 9 {
45            "0".to_string()
46        } else {
47            format!("{}", i + 1)
48        };
49        let label = format!(" {}.{} ", num, app.tab_name(i));
50
51        if i == app.active_tab {
52            spans.push(Span::styled(
53                label,
54                Style::default()
55                    .fg(theme.config_tab_active_fg)
56                    .bg(theme.config_tab_active_bg)
57                    .add_modifier(Modifier::BOLD),
58            ));
59        } else {
60            spans.push(Span::styled(
61                label,
62                Style::default()
63                    .fg(theme.config_tab_inactive)
64                    .bg(theme.bg_title),
65            ));
66        }
67        spans.push(Span::styled(" ", Style::default().bg(theme.bg_title)));
68    }
69
70    // 填充剩余空间
71    let used_width: usize = spans.iter().map(|s| display_width(&s.content)).sum();
72    let fill = (area.width as usize).saturating_sub(used_width);
73    if fill > 0 {
74        spans.push(Span::styled(
75            " ".repeat(fill),
76            Style::default().bg(theme.bg_title),
77        ));
78    }
79
80    let line = Line::from(spans);
81    f.render_widget(Paragraph::new(vec![line]), area);
82}
83
84/// 绘制标题栏
85fn draw_title_bar(
86    f: &mut Frame,
87    app: &HelpApp,
88    area: Rect,
89    theme: &crate::command::chat::theme::Theme,
90) {
91    let title_text = format!("  📖 j help — {}", app.tab_name(app.active_tab));
92    let page_info = format!("{}/{}  ", app.active_tab + 1, app.tab_count);
93
94    let title_w = display_width(&title_text);
95    let page_w = display_width(&page_info);
96    let fill = (area.width as usize).saturating_sub(title_w + page_w);
97
98    let spans = vec![
99        Span::styled(
100            title_text,
101            Style::default()
102                .fg(theme.help_title)
103                .add_modifier(Modifier::BOLD),
104        ),
105        Span::styled(" ".repeat(fill), Style::default()),
106        Span::styled(page_info, Style::default().fg(theme.text_dim)),
107    ];
108
109    // 标题栏占 3 行:空行 + 标题内容 + 分隔线
110    let inner_chunks = Layout::default()
111        .direction(Direction::Vertical)
112        .constraints([
113            Constraint::Length(1),
114            Constraint::Length(1),
115            Constraint::Length(1),
116        ])
117        .split(area);
118
119    // 空行
120    f.render_widget(Paragraph::new(vec![Line::from("")]), inner_chunks[0]);
121
122    // 标题内容
123    f.render_widget(Paragraph::new(vec![Line::from(spans)]), inner_chunks[1]);
124
125    // 分隔线
126    let sep_width = area.width as usize;
127    let sep_line = Line::from(Span::styled(
128        "─".repeat(sep_width),
129        Style::default().fg(theme.separator),
130    ));
131    f.render_widget(Paragraph::new(vec![sep_line]), inner_chunks[2]);
132}
133
134/// 绘制内容区(带滚动)
135fn draw_content(
136    f: &mut Frame,
137    app: &mut HelpApp,
138    area: Rect,
139    _theme: &crate::command::chat::theme::Theme,
140) {
141    let content_width = area.width.saturating_sub(4) as usize; // 左右各留 2 字符
142    let visible_height = area.height as usize;
143
144    // 获取渲染行(带缓存)
145    let all_lines = app.current_tab_lines(content_width).to_vec();
146
147    // 更新 total_lines 并钳制滚动
148    app.clamp_scroll(visible_height);
149
150    let scroll_offset = app.scroll_offset();
151
152    // 给每行加左边距 "  "
153    let display_lines: Vec<Line<'static>> = all_lines
154        .into_iter()
155        .skip(scroll_offset)
156        .take(visible_height)
157        .map(|line| {
158            let mut spans = vec![Span::raw("  ")];
159            spans.extend(line.spans);
160            Line::from(spans)
161        })
162        .collect();
163
164    let paragraph = Paragraph::new(display_lines);
165    f.render_widget(paragraph, area);
166}
167
168/// 绘制底部提示栏
169fn draw_hint_bar(f: &mut Frame, area: Rect, theme: &crate::command::chat::theme::Theme) {
170    let hints: &[(&str, &str)] = &[
171        ("←→", "切换"),
172        ("1-0", "跳转"),
173        ("↑↓", "滚动"),
174        ("PgUp/Dn", "翻页"),
175        ("q", "退出"),
176    ];
177
178    let mut spans: Vec<Span> = Vec::new();
179    spans.push(Span::styled(" ", Style::default().bg(theme.bg_title)));
180
181    for (i, (key, desc)) in hints.iter().enumerate() {
182        if i > 0 {
183            spans.push(Span::styled(" ", Style::default().fg(theme.hint_separator)));
184        }
185        spans.push(Span::styled(
186            format!(" {} ", key),
187            Style::default().fg(theme.hint_key_fg).bg(theme.hint_key_bg),
188        ));
189        spans.push(Span::styled(
190            format!(" {}", desc),
191            Style::default().fg(theme.hint_desc),
192        ));
193    }
194
195    // 填充剩余空间
196    let used_width: usize = spans.iter().map(|s| display_width(&s.content)).sum();
197    let fill = (area.width as usize).saturating_sub(used_width);
198    if fill > 0 {
199        spans.push(Span::raw(" ".repeat(fill)));
200    }
201
202    let line = Line::from(spans);
203    f.render_widget(Paragraph::new(vec![line]), area);
204}