Skip to content

Commit 13babf4

Browse files
committed
feat: bilingual tutorial (EN/PT-BR), case-sensitivity step, and cache highlight fix
- Tutorial steps now carry title_en/title_pt/body_en/body_pt; [L] key toggles language at runtime - Default language is PT-BR; nav hint updates to show the opposite lang option - Editor tutorial gains a new step 0 explaining case-sensitive shortcuts (targets footer) - Run > Details step corrected: hover to inspect, click moves PC (runtime jump) - Cache: fix target_subtab_and_content height — was missing 4-row exec_ctrl gap, highlight now spans subtab header to content bottom correctly - Remove unused target_subtab_hdr dead code
1 parent 6194228 commit 13babf4

13 files changed

Lines changed: 913 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "raven"
3-
version = "1.21.0"
3+
version = "1.22.0"
44
edition = "2024"
55

66
[dependencies]

src/ui/app.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,20 @@ pub(super) struct DocsState {
635635
pub(super) filter_bar_y: std::cell::Cell<u16>,
636636
}
637637

638+
// ── Tutorial state ─────────────────────────────────────────────────────────────
639+
640+
/// State for the interactive guided tutorial ([?] button).
641+
pub struct TutorialState {
642+
pub active: bool,
643+
pub(super) tab: Tab,
644+
pub step_idx: usize,
645+
pub lang: DocsLang,
646+
}
647+
648+
impl Default for TutorialState {
649+
fn default() -> Self { Self { active: false, tab: Tab::Editor, step_idx: 0, lang: DocsLang::PtBr } }
650+
}
651+
638652
// ── Path input bar ─────────────────────────────────────────────────────────────
639653

640654
#[derive(Clone, PartialEq, Default)]
@@ -714,6 +728,9 @@ pub struct App {
714728

715729
// TUI path input bar (fallback when OS file dialog returns None)
716730
pub(super) path_input: PathInput,
731+
732+
// Interactive guided tutorial ([?] button)
733+
pub tutorial: TutorialState,
717734
}
718735

719736
pub(super) fn compute_find_matches(query: &str, lines: &[String]) -> Vec<(usize, usize)> {
@@ -933,6 +950,7 @@ impl App {
933950
ram_override,
934951
splash_start: Some(Instant::now()),
935952
path_input: PathInput::new(),
953+
tutorial: TutorialState::default(),
936954
};
937955
app.assemble_and_load();
938956
app

src/ui/input/keyboard.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,21 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> io::Result<bool> {
107107
return Ok(false);
108108
}
109109

110+
// Tutorial intercept — arrow keys navigate steps, Esc closes
111+
if app.tutorial.active {
112+
use crate::ui::tutorial::{advance_tutorial, retreat_tutorial};
113+
match key.code {
114+
KeyCode::Esc => { app.tutorial.active = false; }
115+
KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => advance_tutorial(app),
116+
KeyCode::Left | KeyCode::Backspace => retreat_tutorial(app),
117+
KeyCode::Char('l') | KeyCode::Char('L') => {
118+
app.tutorial.lang = app.tutorial.lang.toggle();
119+
}
120+
_ => {}
121+
}
122+
return Ok(false);
123+
}
124+
110125
// Help popup intercept — Esc closes, ←/→ navigate pages, any other key closes
111126
if app.help_open {
112127
// Count pages by matching tab
@@ -298,10 +313,14 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> io::Result<bool> {
298313
return Ok(false);
299314
}
300315

301-
// '?' opens/closes help popup from any tab
316+
// '?' opens tutorial (non-Docs tabs) or help popup (Docs tab)
302317
if key.code == KeyCode::Char('?') {
303-
app.help_open = !app.help_open;
304-
app.help_page = 0;
318+
if !matches!(app.tab, Tab::Docs) && !crate::ui::tutorial::get_steps(app.tab).is_empty() {
319+
crate::ui::tutorial::start_tutorial(app);
320+
} else {
321+
app.help_open = !app.help_open;
322+
app.help_page = 0;
323+
}
305324
return Ok(false);
306325
}
307326

src/ui/input/mouse.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ pub fn handle_mouse(app: &mut App, me: MouseEvent, area: Rect) {
1515
app.mouse_x = me.column;
1616
app.mouse_y = me.row;
1717

18+
if app.tutorial.active {
19+
// Left click advances tutorial; right click or Esc handled in keyboard
20+
if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) {
21+
crate::ui::tutorial::advance_tutorial(app);
22+
}
23+
return;
24+
}
25+
1826
if app.show_exit_popup {
1927
handle_exit_popup_mouse(app, me, area);
2028
return;
@@ -46,8 +54,12 @@ pub fn handle_mouse(app: &mut App, me: MouseEvent, area: Rect) {
4654
if me.column >= help_col && me.column < area.x + area.width.saturating_sub(1) {
4755
app.hover_help = true;
4856
if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) {
49-
app.help_open = !app.help_open;
50-
app.help_page = 0;
57+
if !matches!(app.tab, Tab::Docs) && !crate::ui::tutorial::get_steps(app.tab).is_empty() {
58+
crate::ui::tutorial::start_tutorial(app);
59+
} else {
60+
app.help_open = !app.help_open;
61+
app.help_page = 0;
62+
}
5163
}
5264
} else {
5365
let x = me.column.saturating_sub(area.x + 1);

src/ui/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod input;
66
pub mod theme;
77
pub mod view;
88
pub mod console;
9+
pub mod tutorial;
910

1011
pub use app::{run, App};
1112
pub use console::Console;

src/ui/tutorial/mod.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use ratatui::layout::Rect;
2+
use crate::ui::app::App;
3+
4+
pub mod render;
5+
mod steps;
6+
7+
pub use steps::get_steps;
8+
9+
pub type TargetFn = fn(Rect, &App) -> Option<Rect>;
10+
pub type SetupFn = fn(&mut App);
11+
12+
pub struct TutorialStep {
13+
pub title_en: &'static str,
14+
pub title_pt: &'static str,
15+
pub body_en: &'static str,
16+
pub body_pt: &'static str,
17+
pub target: TargetFn,
18+
pub setup: Option<SetupFn>,
19+
}
20+
21+
/// Advance to next step, calling setup if present. Closes tutorial on last step.
22+
pub fn advance_tutorial(app: &mut App) {
23+
let next = app.tutorial.step_idx + 1;
24+
let total = get_steps(app.tutorial.tab).len();
25+
if next >= total {
26+
app.tutorial.active = false;
27+
} else {
28+
app.tutorial.step_idx = next;
29+
let setup = get_steps(app.tutorial.tab)[next].setup;
30+
if let Some(f) = setup { f(app); }
31+
}
32+
}
33+
34+
/// Retreat to previous step, calling setup if present.
35+
pub fn retreat_tutorial(app: &mut App) {
36+
if app.tutorial.step_idx == 0 { return; }
37+
let prev = app.tutorial.step_idx - 1;
38+
app.tutorial.step_idx = prev;
39+
let setup = get_steps(app.tutorial.tab)[prev].setup;
40+
if let Some(f) = setup { f(app); }
41+
}
42+
43+
/// Open tutorial for the given tab, running step-0 setup.
44+
pub fn start_tutorial(app: &mut App) {
45+
let tab = app.tab;
46+
app.tutorial.tab = tab;
47+
app.tutorial.step_idx = 0;
48+
app.tutorial.active = true;
49+
let setup = get_steps(tab)[0].setup;
50+
if let Some(f) = setup { f(app); }
51+
}

src/ui/tutorial/render.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
use ratatui::{
2+
Frame,
3+
prelude::*,
4+
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
5+
};
6+
7+
use crate::ui::app::{App, DocsLang};
8+
use crate::ui::theme;
9+
use super::get_steps;
10+
11+
pub fn render_tutorial_overlay(f: &mut Frame, term: Rect, app: &App) {
12+
let steps = get_steps(app.tutorial.tab);
13+
if steps.is_empty() { return; }
14+
let step = &steps[app.tutorial.step_idx];
15+
let total = steps.len();
16+
let idx = app.tutorial.step_idx;
17+
18+
// Highlight target area
19+
let target = (step.target)(term, app);
20+
if let Some(t) = target {
21+
let highlight = Block::default()
22+
.borders(Borders::ALL)
23+
.border_type(BorderType::Thick)
24+
.border_style(Style::default().fg(Color::Yellow));
25+
f.render_widget(highlight, t);
26+
}
27+
28+
let (title, body) = match app.tutorial.lang {
29+
DocsLang::En => (step.title_en, step.body_en),
30+
DocsLang::PtBr => (step.title_pt, step.body_pt),
31+
};
32+
33+
// Compute popup size
34+
let max_w: u16 = 64.min(term.width.saturating_sub(2));
35+
let inner_w = max_w.saturating_sub(2) as usize;
36+
let body_lines = wrap_text(body, inner_w);
37+
let popup_h: u16 = (body_lines.len() as u16) + 6; // 2 border + 1 title + 1 blank + 1 nav + 1 blank
38+
let popup_h = popup_h.min(term.height.saturating_sub(2));
39+
let popup_w = max_w;
40+
41+
// Position popup
42+
let popup_rect = best_popup_rect(target, popup_w, popup_h, term);
43+
44+
f.render_widget(Clear, popup_rect);
45+
46+
let title_str = format!(" ▶ {} ", title);
47+
let block = Block::default()
48+
.borders(Borders::ALL)
49+
.border_type(BorderType::Rounded)
50+
.border_style(Style::default().fg(Color::Yellow))
51+
.title(Span::styled(title_str, Style::default().fg(Color::Yellow).bold()));
52+
53+
let inner = block.inner(popup_rect);
54+
f.render_widget(block, popup_rect);
55+
56+
// Body text
57+
let body_area = Rect {
58+
x: inner.x,
59+
y: inner.y,
60+
width: inner.width,
61+
height: inner.height.saturating_sub(2),
62+
};
63+
f.render_widget(
64+
Paragraph::new(body)
65+
.style(Style::default().fg(theme::TEXT))
66+
.wrap(Wrap { trim: false }),
67+
body_area,
68+
);
69+
70+
// Nav hint at bottom
71+
let nav_text = match app.tutorial.lang {
72+
DocsLang::En => format!(" ← Prev → Next [L]=PT-BR Esc=close [{}/{}]", idx + 1, total),
73+
DocsLang::PtBr => format!(" ← Ant → Próx [L]=EN Esc=fechar [{}/{}]", idx + 1, total),
74+
};
75+
let nav_area = Rect {
76+
x: inner.x,
77+
y: inner.y + inner.height.saturating_sub(1),
78+
width: inner.width,
79+
height: 1,
80+
};
81+
f.render_widget(
82+
Paragraph::new(nav_text).style(Style::default().fg(theme::LABEL)),
83+
nav_area,
84+
);
85+
}
86+
87+
fn wrap_text(text: &str, width: usize) -> Vec<String> {
88+
if width == 0 { return vec![text.to_string()]; }
89+
let mut lines = Vec::new();
90+
for paragraph in text.split('\n') {
91+
if paragraph.is_empty() {
92+
lines.push(String::new());
93+
continue;
94+
}
95+
let mut current = String::new();
96+
for word in paragraph.split_whitespace() {
97+
if current.is_empty() {
98+
current = word.to_string();
99+
} else if current.len() + 1 + word.len() <= width {
100+
current.push(' ');
101+
current.push_str(word);
102+
} else {
103+
lines.push(current.clone());
104+
current = word.to_string();
105+
}
106+
}
107+
if !current.is_empty() {
108+
lines.push(current);
109+
}
110+
}
111+
lines
112+
}
113+
114+
/// Try positions: below → above → right → left → centered.
115+
fn best_popup_rect(target: Option<Rect>, pw: u16, ph: u16, term: Rect) -> Rect {
116+
let Some(t) = target else {
117+
return centered(pw, ph, term);
118+
};
119+
120+
// Below
121+
let below_y = t.y + t.height;
122+
if below_y + ph <= term.y + term.height {
123+
let x = clamp_x(t.x, pw, term);
124+
return Rect::new(x, below_y, pw, ph);
125+
}
126+
127+
// Above
128+
if t.y >= term.y + ph {
129+
let x = clamp_x(t.x, pw, term);
130+
return Rect::new(x, t.y - ph, pw, ph);
131+
}
132+
133+
// Right
134+
let right_x = t.x + t.width;
135+
if right_x + pw <= term.x + term.width {
136+
let y = clamp_y(t.y, ph, term);
137+
return Rect::new(right_x, y, pw, ph);
138+
}
139+
140+
// Left
141+
if t.x >= term.x + pw {
142+
let y = clamp_y(t.y, ph, term);
143+
return Rect::new(t.x - pw, y, pw, ph);
144+
}
145+
146+
// Centered fallback
147+
centered(pw, ph, term)
148+
}
149+
150+
fn centered(pw: u16, ph: u16, term: Rect) -> Rect {
151+
Rect::new(
152+
term.x + term.width.saturating_sub(pw) / 2,
153+
term.y + term.height.saturating_sub(ph) / 2,
154+
pw,
155+
ph,
156+
)
157+
}
158+
159+
fn clamp_x(preferred: u16, pw: u16, term: Rect) -> u16 {
160+
let max_x = (term.x + term.width).saturating_sub(pw);
161+
preferred.min(max_x).max(term.x)
162+
}
163+
164+
fn clamp_y(preferred: u16, ph: u16, term: Rect) -> u16 {
165+
let max_y = (term.y + term.height).saturating_sub(ph);
166+
preferred.min(max_y).max(term.y)
167+
}

0 commit comments

Comments
 (0)