First improvement pass

This commit is contained in:
2026-02-10 23:32:57 -05:00
parent f65e3d8413
commit c78a8a90a3
26 changed files with 13200 additions and 207 deletions

View File

@@ -1,12 +1,14 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Widget};
use crate::keyboard::finger::{self, Finger, Hand};
use crate::ui::theme::Theme;
pub struct KeyboardDiagram<'a> {
pub focused_key: Option<char>,
pub next_key: Option<char>,
pub unlocked_keys: &'a [char],
pub theme: &'a Theme,
}
@@ -14,11 +16,13 @@ pub struct KeyboardDiagram<'a> {
impl<'a> KeyboardDiagram<'a> {
pub fn new(
focused_key: Option<char>,
next_key: Option<char>,
unlocked_keys: &'a [char],
theme: &'a Theme,
) -> Self {
Self {
focused_key,
next_key,
unlocked_keys,
theme,
}
@@ -31,6 +35,21 @@ const ROWS: &[&[char]] = &[
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
fn finger_color(ch: char) -> Color {
let assignment = finger::qwerty_finger(ch);
match (assignment.hand, assignment.finger) {
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
_ => Color::Rgb(120, 120, 120),
}
}
impl Widget for KeyboardDiagram<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -42,12 +61,12 @@ impl Widget for KeyboardDiagram<'_> {
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 20 {
if inner.height < 3 || inner.width < 30 {
return;
}
let key_width: u16 = 4;
let offsets: &[u16] = &[1, 2, 4];
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
for (row_idx, row) in ROWS.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -59,26 +78,33 @@ impl Widget for KeyboardDiagram<'_> {
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
if x + key_width > inner.x + inner.width {
break;
}
let is_unlocked = self.unlocked_keys.contains(&key);
let is_focused = self.focused_key == Some(key);
let is_next = self.next_key == Some(key);
let style = if is_focused {
let style = if is_next {
Style::default()
.fg(colors.bg())
.bg(colors.accent())
} else if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
} else if is_unlocked {
Style::default().fg(colors.fg()).bg(colors.accent_dim())
Style::default()
.fg(colors.fg())
.bg(finger_color(key))
} else {
Style::default()
.fg(colors.text_pending())
.bg(colors.bg())
};
let display = format!("[{key}]");
let display = format!("[ {key} ]");
buf.set_string(x, y, &display, style);
}
}

View File

@@ -4,18 +4,34 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::engine::key_stats::KeyStatsStore;
use crate::session::result::LessonResult;
use crate::ui::components::chart::WpmChart;
use crate::ui::theme::Theme;
pub struct StatsDashboard<'a> {
pub history: &'a [LessonResult],
pub key_stats: &'a KeyStatsStore,
pub active_tab: usize,
pub target_wpm: u32,
pub theme: &'a Theme,
}
impl<'a> StatsDashboard<'a> {
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
Self { history, theme }
pub fn new(
history: &'a [LessonResult],
key_stats: &'a KeyStatsStore,
active_tab: usize,
target_wpm: u32,
theme: &'a Theme,
) -> Self {
Self {
history,
key_stats,
active_tab,
target_wpm,
theme,
}
}
}
@@ -42,12 +58,64 @@ impl Widget for StatsDashboard<'_> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(2),
])
.split(inner);
// Tab header
let tabs = ["[D] Dashboard", "[H] History", "[K] Keystrokes"];
let tab_spans: Vec<Span> = tabs
.iter()
.enumerate()
.flat_map(|(i, &label)| {
let style = if i == self.active_tab {
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(colors.text_pending())
};
vec![
Span::styled(format!(" {label} "), style),
Span::raw(" "),
]
})
.collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
// Tab content
match self.active_tab {
0 => self.render_dashboard_tab(layout[1], buf),
1 => self.render_history_tab(layout[1], buf),
2 => self.render_keystrokes_tab(layout[1], buf),
_ => {}
}
// Footer
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab",
Style::default().fg(colors.accent()),
)));
footer.render(layout[2], buf);
}
}
impl StatsDashboard<'_> {
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(8),
])
.split(area);
// Summary stats
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self
@@ -57,63 +125,616 @@ impl Widget for StatsDashboard<'_> {
.fold(0.0f64, f64::max);
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
let total_lessons = self.history.len();
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
let total_str = format!("{total_lessons}");
let total_str = format!("{}", self.history.len());
let avg_wpm_str = format!("{avg_wpm:.0}");
let best_wpm_str = format!("{best_wpm:.0}");
let avg_acc_str = format!("{avg_accuracy:.1}%");
let time_str = format_duration(total_time);
let summary = vec![
Line::from(vec![
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
Span::styled(
&*total_str,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(vec![
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*avg_acc_str,
Style::default().fg(if avg_accuracy >= 95.0 {
colors.success()
} else {
} else if avg_accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
]),
];
Paragraph::new(summary).render(layout[0], buf);
// Progress bars
self.render_progress_bars(layout[1], buf);
// Charts
let chart_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[2]);
// WPM chart
let wpm_data: Vec<(f64, f64)> = self
.history
.iter()
.rev()
.take(50)
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.collect();
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
// Accuracy chart
let acc_data: Vec<(f64, f64)> = self
.history
.iter()
.rev()
.take(50)
.enumerate()
.map(|(i, r)| (i as f64, r.accuracy))
.collect();
render_accuracy_chart(&acc_data, self.theme, chart_layout[1], buf);
}
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(area);
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
// WPM progress
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
render_text_bar(&wpm_label, wpm_pct / 100.0, colors.accent(), colors.bar_empty(), layout[0], buf);
// Accuracy progress
let acc_pct = avg_accuracy.min(100.0);
let acc_label = format!(" Acc: {acc_pct:.1}%");
let acc_color = if acc_pct >= 95.0 {
colors.success()
} else if acc_pct >= 85.0 {
colors.warning()
} else {
colors.error()
};
render_text_bar(&acc_label, acc_pct / 100.0, acc_color, colors.bar_empty(), layout[1], buf);
// Level progress
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
let level = ((total_score / 100.0).sqrt() as u32).max(1);
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
let current_level_score = (level as f64).powi(2) * 100.0;
let level_pct = ((total_score - current_level_score) / (next_level_score - current_level_score)).clamp(0.0, 1.0);
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
render_text_bar(&level_label, level_pct, colors.focused_key(), colors.bar_empty(), layout[2], buf);
}
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10),
Constraint::Length(8),
])
.split(area);
// Recent tests table
let header = Line::from(vec![
Span::styled(
" # WPM Raw Acc% Time Date",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
]);
let mut lines = vec![header, Line::from(Span::styled(
" ─────────────────────────────────────────────",
Style::default().fg(colors.border()),
))];
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
let total = self.history.len();
for (i, result) in recent.iter().enumerate() {
let idx = total - i;
let raw_wpm = result.cpm / 5.0;
let time_str = format!("{:.1}s", result.elapsed_secs);
let date_str = result.timestamp.format("%m/%d %H:%M").to_string();
let idx_str = format!("{idx:>3}");
let wpm_str = format!("{:>6.0}", result.wpm);
let raw_str = format!("{:>6.0}", raw_wpm);
let acc_str = format!("{:>6.1}%", result.accuracy);
let row = format!(" {idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}");
let acc_color = if result.accuracy >= 95.0 {
colors.success()
} else if result.accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
};
lines.push(Line::from(Span::styled(row, Style::default().fg(acc_color))));
}
Paragraph::new(lines).render(layout[0], buf);
// Per-key speed
self.render_per_key_speed(layout[1], buf);
}
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Per-Key Average Speed (ms) ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 52 || inner.height < 2 {
return;
}
let letters: Vec<char> = ('a'..='z').collect();
let max_time = letters
.iter()
.filter_map(|&ch| self.key_stats.stats.get(&ch))
.map(|s| s.filtered_time_ms)
.fold(0.0f64, f64::max)
.max(1.0);
// Render bar chart: letter label on row 0, bar on row 1
let bar_width = (inner.width as usize).min(52) / 26;
let bar_width = bar_width.max(1) as u16;
for (i, &ch) in letters.iter().enumerate() {
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
if x >= inner.x + inner.width {
break;
}
let time = self
.key_stats
.stats
.get(&ch)
.map(|s| s.filtered_time_ms)
.unwrap_or(0.0);
let ratio = time / max_time;
let color = if ratio < 0.3 {
colors.success()
} else if ratio < 0.6 {
colors.accent()
} else {
colors.error()
};
// Letter label
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
// Simple bar indicator
if inner.height >= 2 {
let bar_char = if time > 0.0 {
match (ratio * 8.0) as u8 {
0 => '▁',
1 => '▂',
2 => '▃',
3 => '▄',
4 => '▅',
5 => '▆',
6 => '▇',
_ => '█',
}
} else {
' '
};
buf.set_string(
x,
inner.y + 1,
&bar_char.to_string(),
Style::default().fg(color),
);
}
}
let _ = bar_width;
}
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7),
Constraint::Min(5),
Constraint::Length(6),
])
.split(area);
// Keyboard accuracy heatmap
self.render_keyboard_heatmap(layout[0], buf);
// Slowest/Fastest keys
let key_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(layout[1]);
self.render_slowest_keys(key_layout[0], buf);
self.render_fastest_keys(key_layout[1], buf);
self.render_char_stats(key_layout[2], buf);
// Word/Character stats summary
self.render_overall_stats(layout[2], buf);
}
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Keyboard Accuracy ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 40 {
return;
}
let rows: &[&[char]] = &[
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
let offsets: &[u16] = &[1, 3, 5];
let key_width: u16 = 4;
for (row_idx, row) in rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
if y >= inner.y + inner.height {
break;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
break;
}
let accuracy = self.get_key_accuracy(key);
let color = if accuracy >= 100.0 {
colors.text_pending()
} else if accuracy >= 90.0 {
colors.warning()
} else if accuracy > 0.0 {
colors.error()
} else {
colors.text_pending()
};
let display = format!("[{key}]");
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
}
}
}
fn get_key_accuracy(&self, key: char) -> f64 {
let mut correct = 0usize;
let mut total = 0usize;
for result in self.history {
for kt in &result.per_key_times {
if kt.key == key {
total += 1;
if kt.correct {
correct += 1;
}
}
}
}
if total == 0 {
return 0.0;
}
correct as f64 / total as f64 * 100.0
}
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Slowest ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut key_times: Vec<(char, f64)> = self
.key_stats
.stats
.iter()
.filter(|(_, s)| s.sample_count > 0)
.map(|(&ch, s)| (ch, s.filtered_time_ms))
.collect();
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let text = format!(" '{ch}' {time:.0}ms");
buf.set_string(
inner.x,
y,
&text,
Style::default().fg(colors.error()),
);
}
}
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Fastest ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut key_times: Vec<(char, f64)> = self
.key_stats
.stats
.iter()
.filter(|(_, s)| s.sample_count > 0)
.map(|(&ch, s)| (ch, s.filtered_time_ms))
.collect();
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let text = format!(" '{ch}' {time:.0}ms");
buf.set_string(
inner.x,
y,
&text,
Style::default().fg(colors.success()),
);
}
}
fn render_char_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Key Stats ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut total_correct = 0usize;
let mut total_incorrect = 0usize;
for result in self.history {
total_correct += result.correct;
total_incorrect += result.incorrect;
}
let total = total_correct + total_incorrect;
let overall_acc = if total > 0 {
total_correct as f64 / total as f64 * 100.0
} else {
0.0
};
let lines = [
format!(" Total: {total}"),
format!(" Correct: {total_correct}"),
format!(" Wrong: {total_incorrect}"),
format!(" Acc: {overall_acc:.1}%"),
];
for (i, line) in lines.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, y, line, Style::default().fg(colors.fg()));
}
}
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Overall ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum();
let total_correct: usize = self.history.iter().map(|r| r.correct).sum();
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
let lines = vec![
Line::from(vec![
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_chars}"),
Style::default().fg(colors.accent()),
),
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_correct}"),
Style::default().fg(colors.success()),
),
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_incorrect}"),
Style::default().fg(if total_incorrect > 0 {
colors.error()
} else {
colors.success()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(
format_duration(total_time),
Style::default().fg(colors.text_pending()),
),
]),
];
Paragraph::new(summary).render(layout[0], buf);
let chart_data: Vec<(f64, f64)> = self
.history
.iter()
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.collect();
WpmChart::new(&chart_data, self.theme).render(layout[1], buf);
let help = Paragraph::new(Line::from(Span::styled(
" [ESC] Back to menu",
Style::default().fg(colors.accent()),
)));
help.render(layout[2], buf);
Paragraph::new(lines).render(inner, buf);
}
}
fn render_text_bar(
label: &str,
ratio: f64,
fill_color: ratatui::style::Color,
empty_color: ratatui::style::Color,
area: Rect,
buf: &mut Buffer,
) {
if area.height < 2 || area.width < 10 {
return;
}
// Label on first line
buf.set_string(
area.x,
area.y,
label,
Style::default().fg(fill_color),
);
// Bar on second line
let bar_width = (area.width as usize).saturating_sub(4);
let filled = (ratio * bar_width as f64) as usize;
let bar_y = area.y + 1;
buf.set_string(area.x, bar_y, " ", Style::default());
for i in 0..bar_width {
let x = area.x + 2 + i as u16;
if x >= area.x + area.width {
break;
}
let (ch, color) = if i < filled {
('█', fill_color)
} else {
('░', empty_color)
};
buf.set_string(x, bar_y, &ch.to_string(), Style::default().fg(color));
}
}
fn render_accuracy_chart(
data: &[(f64, f64)],
theme: &Theme,
area: Rect,
buf: &mut Buffer,
) {
use ratatui::symbols;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
let colors = &theme.colors;
if data.is_empty() {
let block = Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.success()))
.data(data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("%")
.style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]),
);
chart.render(area, buf);
}
fn format_duration(secs: f64) -> String {
let total = secs as u64;
let hours = total / 3600;
let mins = (total % 3600) / 60;
let s = total % 60;
if hours > 0 {
format!("{hours}h {mins}m {s}s")
} else if mins > 0 {
format!("{mins}m {s}s")
} else {
format!("{s}s")
}
}