Skip to content

Commit 03efba5

Browse files
Merge pull request #143 from unhappychoice/feature/ui-improvements
feat(ui): enhance typing screen with improved layout and real-time updates
2 parents 9087328 + 141dbba commit 03efba5

File tree

3 files changed

+141
-46
lines changed

3 files changed

+141
-46
lines changed

src/game/screens/typing_screen.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ impl TypingScreen {
139139
&self.challenge_text,
140140
self.current_position,
141141
self.mistakes,
142-
&self.start_time,
143142
&self.line_starts,
144143
&self.comment_ranges,
145144
self.challenge.as_ref(),
@@ -177,6 +176,9 @@ impl TypingScreen {
177176
}
178177
}
179178
}
179+
} else {
180+
// No keyboard event, but update display periodically for real-time metrics
181+
self.update_display()?;
180182
}
181183
}
182184

@@ -195,7 +197,6 @@ impl TypingScreen {
195197
&self.challenge_text,
196198
self.current_position,
197199
self.mistakes,
198-
&self.start_time,
199200
&self.line_starts,
200201
&self.comment_ranges,
201202
self.challenge.as_ref(),
@@ -233,6 +234,9 @@ impl TypingScreen {
233234
}
234235
}
235236
}
237+
} else {
238+
// No keyboard event, but update display periodically for real-time metrics
239+
self.update_display()?;
236240
}
237241
}
238242

@@ -248,7 +252,6 @@ impl TypingScreen {
248252
&self.challenge_text,
249253
self.current_position,
250254
self.mistakes,
251-
&self.start_time,
252255
&self.line_starts,
253256
&self.comment_ranges,
254257
self.challenge.as_ref(),
@@ -277,6 +280,9 @@ impl TypingScreen {
277280
}
278281
}
279282
}
283+
} else {
284+
// No keyboard event, but update display periodically for real-time metrics
285+
self.update_display()?;
280286
}
281287
};
282288

@@ -425,7 +431,6 @@ impl TypingScreen {
425431
&self.challenge_text,
426432
self.current_position,
427433
self.mistakes,
428-
&self.start_time,
429434
&self.line_starts,
430435
&self.comment_ranges,
431436
self.challenge.as_ref(),

src/game/stage_renderer.rs

Lines changed: 126 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ impl StageRenderer {
3737
challenge_text: &str,
3838
current_position: usize,
3939
mistakes: usize,
40-
start_time: &std::time::Instant,
4140
line_starts: &[usize],
4241
comment_ranges: &[(usize, usize)],
4342
challenge: Option<&Challenge>,
@@ -80,15 +79,17 @@ impl StageRenderer {
8079
comment_ranges,
8180
current_mistake_position,
8281
terminal_size.width,
82+
challenge,
8383
);
8484

85+
let elapsed_time = scoring_engine.get_elapsed_time();
8586
let metrics = crate::scoring::engine::ScoringEngine::calculate_real_time_result(
8687
current_position,
8788
mistakes,
88-
start_time,
89+
elapsed_time,
8990
);
9091
let current_line = self.find_line_for_position(current_position, line_starts);
91-
let elapsed_secs = scoring_engine.get_elapsed_time().as_secs();
92+
let elapsed_secs = elapsed_time.as_secs();
9293

9394
let streak = scoring_engine.get_current_streak();
9495
let first_line = format!(
@@ -99,48 +100,90 @@ impl StageRenderer {
99100
self.terminal.draw(|f| {
100101
let chunks = Layout::default()
101102
.direction(Direction::Vertical)
103+
.margin(1) // Add margin for better spacing
102104
.constraints(
103105
[
104-
Constraint::Length(4), // Header with metrics
105-
Constraint::Min(1), // Content
106-
Constraint::Length(1), // Progress bar at bottom
106+
Constraint::Length(3), // Header (more compact - only challenge info)
107+
Constraint::Min(3), // Content (minimum height)
108+
Constraint::Length(3), // Metrics section (compact)
109+
Constraint::Length(3), // Progress bar (compact)
107110
]
108111
.as_ref(),
109112
)
110113
.split(f.size());
111114

112-
// Header with consolidated information
113-
let header = Paragraph::new(vec![
114-
Line::from(header_text.clone()),
115-
Line::from(vec![Span::styled(
116-
first_line.clone(),
117-
Style::default().fg(Color::White),
118-
)]),
119-
Line::from(vec![
120-
Span::styled("[ESC]", Style::default().fg(Color::Cyan)),
121-
Span::styled(" Options", Style::default().fg(Color::White)),
122-
]),
123-
])
124-
.block(Block::default().borders(Borders::BOTTOM));
115+
// Header with basic info
116+
let header = Paragraph::new(vec![Line::from(header_text.clone())]).block(
117+
Block::default()
118+
.borders(Borders::ALL)
119+
.border_style(Style::default().fg(Color::Cyan))
120+
.title("Challenge")
121+
.title_style(Style::default().fg(Color::Cyan))
122+
.padding(ratatui::widgets::Padding::horizontal(1)),
123+
); // Only horizontal padding
125124
f.render_widget(header, chunks[0]);
126125

127-
// Content with syntax highlighting and cursor
128-
let scroll_offset = if current_line > chunks[1].height as usize / 2 {
129-
(current_line - chunks[1].height as usize / 2) as u16
126+
// Content with syntax highlighting and cursor with padding
127+
let scroll_offset = if current_line > chunks[1].height.saturating_sub(2) as usize / 2 {
128+
(current_line - chunks[1].height.saturating_sub(2) as usize / 2) as u16
130129
} else {
131130
0
132131
};
133132

134-
let content =
135-
Paragraph::new(Text::from(content_spans.clone())).scroll((scroll_offset, 0));
133+
let content = Paragraph::new(Text::from(content_spans.clone()))
134+
.scroll((scroll_offset, 0))
135+
.block(
136+
Block::default()
137+
.borders(Borders::ALL)
138+
.border_style(Style::default().fg(Color::Blue))
139+
.title("Code")
140+
.title_style(Style::default().fg(Color::LightBlue))
141+
.padding(ratatui::widgets::Padding::uniform(1)),
142+
);
136143
f.render_widget(content, chunks[1]);
137144

138-
// Progress bar at the bottom, full width
139-
let terminal_width = f.size().width as u8;
140-
let full_width_progress = Self::create_progress_bar(progress_percent, terminal_width);
141-
let progress_widget =
142-
Paragraph::new(full_width_progress).style(Style::default().fg(Color::White));
143-
f.render_widget(progress_widget, chunks[2]);
145+
// Metrics section below the code
146+
let metrics_widget = Paragraph::new(vec![Line::from(vec![Span::styled(
147+
first_line.clone(),
148+
Style::default().fg(Color::White),
149+
)])])
150+
.block(
151+
Block::default()
152+
.borders(Borders::ALL)
153+
.border_style(Style::default().fg(Color::Yellow))
154+
.title("Metrics")
155+
.title_style(Style::default().fg(Color::Yellow))
156+
.padding(ratatui::widgets::Padding::horizontal(1)),
157+
); // Only horizontal padding
158+
f.render_widget(metrics_widget, chunks[2]);
159+
160+
// Progress bar in its own bordered box with different color
161+
let progress_width = chunks[3].width.saturating_sub(4) as u8; // Account for borders and padding
162+
let full_width_progress = Self::create_progress_bar(progress_percent, progress_width);
163+
let progress_widget = Paragraph::new(full_width_progress)
164+
.style(Style::default().fg(Color::Green))
165+
.block(
166+
Block::default()
167+
.borders(Borders::ALL)
168+
.border_style(Style::default().fg(Color::Green))
169+
.title("Progress")
170+
.title_style(Style::default().fg(Color::Green)),
171+
)
172+
.alignment(ratatui::layout::Alignment::Center);
173+
f.render_widget(progress_widget, chunks[3]);
174+
175+
// Render [ESC] Options in bottom left without border
176+
let esc_area = ratatui::layout::Rect {
177+
x: 1, // Left margin
178+
y: f.size().height.saturating_sub(1), // Bottom of screen
179+
width: 15, // Width for "[ESC] Options"
180+
height: 1,
181+
};
182+
let esc_text = Paragraph::new(vec![Line::from(vec![
183+
Span::styled("[ESC]", Style::default().fg(Color::LightBlue)),
184+
Span::styled(" Options", Style::default().fg(Color::White)),
185+
])]);
186+
f.render_widget(esc_text, esc_area);
144187

145188
// Render dialog if shown
146189
if dialog_shown {
@@ -159,20 +202,44 @@ impl StageRenderer {
159202
comment_ranges: &[(usize, usize)],
160203
current_mistake_position: Option<usize>,
161204
terminal_width: u16,
205+
challenge: Option<&Challenge>,
162206
) -> Vec<Line<'static>> {
163207
let mut lines = Vec::new();
164208
let mut current_line_spans = Vec::new();
165209
let mut current_line_width = 0u16;
166-
let max_width = terminal_width.saturating_sub(1);
210+
211+
// Reserve space for line numbers (assume max 4 digits + 2 spaces)
212+
let line_number_width = 6u16;
213+
let max_width = terminal_width.saturating_sub(line_number_width + 1);
167214

168215
// Pre-calculate all character properties to avoid O(n²) complexity
169216
let skip_cache = self.create_skip_cache(line_starts, comment_ranges);
170217
let comment_cache = self.create_comment_cache(comment_ranges);
171218
let current_line_number = self.find_line_for_position(current_position, line_starts);
172219

173220
let mut line_number = 0;
221+
let mut line_start = true;
222+
223+
// Get the starting line number from challenge, default to 1
224+
let start_line_number = challenge.and_then(|c| c.start_line).unwrap_or(1);
174225

175226
for (i, &ch) in self.chars.iter().enumerate() {
227+
// Add line number at the start of each line
228+
if line_start {
229+
let actual_line_number = start_line_number + line_number;
230+
let line_num_text = format!("{:>4} │ ", actual_line_number);
231+
let line_num_style = if line_number == current_line_number {
232+
Style::default()
233+
.fg(Color::Yellow)
234+
.add_modifier(ratatui::style::Modifier::BOLD)
235+
} else {
236+
Style::default().fg(Color::DarkGray)
237+
};
238+
current_line_spans.push(Span::styled(line_num_text, line_num_style));
239+
current_line_width += line_number_width;
240+
line_start = false;
241+
}
242+
176243
// Handle explicit newlines
177244
if ch == '\n' {
178245
// Use cached properties for newline styling
@@ -198,6 +265,7 @@ impl StageRenderer {
198265
current_line_spans = Vec::new();
199266
current_line_width = 0;
200267
line_number += 1;
268+
line_start = true; // Next iteration will be start of new line
201269
continue;
202270
}
203271

@@ -234,10 +302,32 @@ impl StageRenderer {
234302

235303
if !current_line_spans.is_empty() {
236304
lines.push(Line::from(current_line_spans));
305+
} else if line_start {
306+
// Handle case where file ends without content on last line
307+
let actual_line_number = start_line_number + line_number;
308+
let line_num_text = format!("{:>4} │ ", actual_line_number);
309+
let line_num_style = if line_number == current_line_number {
310+
Style::default()
311+
.fg(Color::Yellow)
312+
.add_modifier(ratatui::style::Modifier::BOLD)
313+
} else {
314+
Style::default().fg(Color::DarkGray)
315+
};
316+
lines.push(Line::from(vec![Span::styled(
317+
line_num_text,
318+
line_num_style,
319+
)]));
237320
}
238321

239322
if lines.is_empty() {
240-
lines.push(Line::from(""));
323+
let line_num_text = format!("{:>4} │ ", start_line_number);
324+
let line_num_style = Style::default()
325+
.fg(Color::Yellow)
326+
.add_modifier(ratatui::style::Modifier::BOLD);
327+
lines.push(Line::from(vec![Span::styled(
328+
line_num_text,
329+
line_num_style,
330+
)]));
241331
}
242332

243333
lines
@@ -436,7 +526,7 @@ impl StageRenderer {
436526
Span::styled(
437527
"[S] ",
438528
Style::default()
439-
.fg(Color::Yellow)
529+
.fg(Color::Cyan)
440530
.add_modifier(Modifier::BOLD),
441531
)
442532
} else {
@@ -472,7 +562,7 @@ impl StageRenderer {
472562
Span::styled(
473563
"[ESC] ",
474564
Style::default()
475-
.fg(Color::Green)
565+
.fg(Color::LightBlue)
476566
.add_modifier(Modifier::BOLD),
477567
),
478568
Span::styled("Back to game", Style::default().fg(Color::White)),
@@ -487,10 +577,10 @@ impl StageRenderer {
487577
.title("Game Options")
488578
.title_style(
489579
Style::default()
490-
.fg(Color::Cyan)
580+
.fg(Color::LightBlue)
491581
.add_modifier(Modifier::BOLD),
492582
)
493-
.border_style(Style::default().fg(Color::Cyan)),
583+
.border_style(Style::default().fg(Color::Blue)),
494584
)
495585
.alignment(ratatui::layout::Alignment::Center);
496586

src/scoring/engine.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -493,24 +493,24 @@ impl ScoringEngine {
493493
}
494494

495495
/// Calculate metrics from current position during real-time typing
496-
/// This uses the same logic as the full ScoringEngine but works with current state
496+
/// This version correctly handles paused time by using the actual elapsed time
497497
pub fn calculate_real_time_result(
498498
current_position: usize,
499499
mistakes: usize,
500-
start_time: &std::time::Instant,
500+
elapsed_time: std::time::Duration,
501501
) -> StageResult {
502-
// Create temporary engine with real-time data
502+
// Create temporary engine with real-time data using correct elapsed time
503503
let mut temp_engine = ScoringEngine::new(String::new());
504-
temp_engine.start_time = Some(*start_time);
505-
temp_engine.recorded_duration = Some(start_time.elapsed());
504+
temp_engine.start_time = Some(std::time::Instant::now() - elapsed_time);
505+
temp_engine.recorded_duration = Some(elapsed_time);
506506

507507
// Simulate keystrokes for calculations
508508
for i in 0..current_position {
509509
temp_engine.keystrokes.push(Keystroke {
510510
character: 'x', // Placeholder
511511
position: i,
512512
is_correct: i < current_position.saturating_sub(mistakes),
513-
timestamp: *start_time,
513+
timestamp: temp_engine.start_time.unwrap(),
514514
});
515515
}
516516

0 commit comments

Comments
 (0)