practicode 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.rs CHANGED
@@ -4,8 +4,8 @@ use crate::{
4
4
  AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
5
5
  UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
6
6
  load_state, localized, next_problem, normalize_ai_provider, normalize_language,
7
- normalize_next_source, previous_problem, problem_by_id, record_pass, render_problem,
8
- save_state, template_for,
7
+ normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
8
+ render_problem, save_state, template_for, ui_text,
9
9
  },
10
10
  text::{
11
11
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -17,7 +17,7 @@ use ratatui::{
17
17
  DefaultTerminal, Frame,
18
18
  layout::{Constraint, Direction, Layout, Position, Rect},
19
19
  style::{Color, Modifier, Style},
20
- widgets::{Block, Borders, Paragraph, Wrap},
20
+ widgets::{Block, Borders, Clear, Paragraph, Wrap},
21
21
  };
22
22
  use std::{
23
23
  collections::HashMap,
@@ -28,46 +28,220 @@ use std::{
28
28
  time::Duration,
29
29
  };
30
30
 
31
- pub const HELP: &str = r#"# Help
32
-
33
- ## Daily loop
34
-
35
- 1. Type code in the right pane.
36
- 2. Press `Esc`, then `/run`.
37
- 3. Use `/next` when it passes.
38
-
39
- ## Commands
40
-
41
- - `/run` judge current submission
42
- - `/edit` focus the code editor
43
- - `/next [request]` next problem, optionally with a request
44
- - `/prev` previous problem
45
- - `/list` choose from problem list
46
- - `/open 2` open by number, id, or slug
47
- - `/giveup` show answer
48
- - `/ai hint` ask the selected AI about current problem + code
49
- - `/provider codex|claude`
50
- - `/model auto|sonnet|opus|...`
51
- - `/note prefer strings this week`
52
- - `/notes` show next-problem notes
53
- - `/lang python|ts|java|rust`
54
- - `/ui ko|en`
55
- - `/theme dark|light`
56
- - `/source bank|ai`
57
- - `/exit` quit
58
-
59
- ## Keys
60
-
61
- - `Esc` leaves the editor or output pane
62
- - `/` opens the command bar when the editor is not focused
63
- - `?` opens this help when the editor is not focused
64
- - `up/down` or `j/k` move in `/list`
65
-
66
- ## Debug prints
31
+ #[derive(Clone, Copy)]
32
+ struct CommandHint {
33
+ insert: &'static str,
34
+ display: &'static str,
35
+ desc_key: &'static str,
36
+ keep_open: bool,
37
+ help: bool,
38
+ }
67
39
 
68
- - stdout prints are shown when a case fails
69
- - stderr prints are shown without affecting the expected stdout
70
- "#;
40
+ const COMMAND_HINTS: &[CommandHint] = &[
41
+ CommandHint {
42
+ insert: "run",
43
+ display: "/run",
44
+ desc_key: "cmd_run",
45
+ keep_open: false,
46
+ help: true,
47
+ },
48
+ CommandHint {
49
+ insert: "edit",
50
+ display: "/edit",
51
+ desc_key: "cmd_edit",
52
+ keep_open: false,
53
+ help: true,
54
+ },
55
+ CommandHint {
56
+ insert: "next",
57
+ display: "/next",
58
+ desc_key: "cmd_next",
59
+ keep_open: false,
60
+ help: true,
61
+ },
62
+ CommandHint {
63
+ insert: "prev",
64
+ display: "/prev",
65
+ desc_key: "cmd_prev",
66
+ keep_open: false,
67
+ help: true,
68
+ },
69
+ CommandHint {
70
+ insert: "list",
71
+ display: "/list",
72
+ desc_key: "cmd_list",
73
+ keep_open: false,
74
+ help: true,
75
+ },
76
+ CommandHint {
77
+ insert: "open ",
78
+ display: "/open <id>",
79
+ desc_key: "cmd_open",
80
+ keep_open: true,
81
+ help: true,
82
+ },
83
+ CommandHint {
84
+ insert: "giveup",
85
+ display: "/giveup",
86
+ desc_key: "cmd_giveup",
87
+ keep_open: false,
88
+ help: true,
89
+ },
90
+ CommandHint {
91
+ insert: "ai ",
92
+ display: "/ai <prompt>",
93
+ desc_key: "cmd_ai",
94
+ keep_open: true,
95
+ help: true,
96
+ },
97
+ CommandHint {
98
+ insert: "provider codex",
99
+ display: "/provider codex",
100
+ desc_key: "cmd_provider",
101
+ keep_open: false,
102
+ help: true,
103
+ },
104
+ CommandHint {
105
+ insert: "provider claude",
106
+ display: "/provider claude",
107
+ desc_key: "cmd_provider",
108
+ keep_open: false,
109
+ help: false,
110
+ },
111
+ CommandHint {
112
+ insert: "model auto",
113
+ display: "/model auto",
114
+ desc_key: "cmd_model",
115
+ keep_open: false,
116
+ help: true,
117
+ },
118
+ CommandHint {
119
+ insert: "model ",
120
+ display: "/model <name>",
121
+ desc_key: "cmd_model",
122
+ keep_open: true,
123
+ help: false,
124
+ },
125
+ CommandHint {
126
+ insert: "note ",
127
+ display: "/note <text>",
128
+ desc_key: "cmd_note",
129
+ keep_open: true,
130
+ help: true,
131
+ },
132
+ CommandHint {
133
+ insert: "notes",
134
+ display: "/notes",
135
+ desc_key: "cmd_notes",
136
+ keep_open: false,
137
+ help: true,
138
+ },
139
+ CommandHint {
140
+ insert: "lang python",
141
+ display: "/lang python",
142
+ desc_key: "cmd_lang",
143
+ keep_open: false,
144
+ help: true,
145
+ },
146
+ CommandHint {
147
+ insert: "lang ts",
148
+ display: "/lang ts",
149
+ desc_key: "cmd_lang",
150
+ keep_open: false,
151
+ help: false,
152
+ },
153
+ CommandHint {
154
+ insert: "lang java",
155
+ display: "/lang java",
156
+ desc_key: "cmd_lang",
157
+ keep_open: false,
158
+ help: false,
159
+ },
160
+ CommandHint {
161
+ insert: "lang rust",
162
+ display: "/lang rust",
163
+ desc_key: "cmd_lang",
164
+ keep_open: false,
165
+ help: false,
166
+ },
167
+ CommandHint {
168
+ insert: "ui en",
169
+ display: "/ui en",
170
+ desc_key: "cmd_ui",
171
+ keep_open: false,
172
+ help: true,
173
+ },
174
+ CommandHint {
175
+ insert: "ui ko",
176
+ display: "/ui ko",
177
+ desc_key: "cmd_ui",
178
+ keep_open: false,
179
+ help: false,
180
+ },
181
+ CommandHint {
182
+ insert: "ui ja",
183
+ display: "/ui ja",
184
+ desc_key: "cmd_ui",
185
+ keep_open: false,
186
+ help: false,
187
+ },
188
+ CommandHint {
189
+ insert: "ui zh",
190
+ display: "/ui zh",
191
+ desc_key: "cmd_ui",
192
+ keep_open: false,
193
+ help: false,
194
+ },
195
+ CommandHint {
196
+ insert: "ui es",
197
+ display: "/ui es",
198
+ desc_key: "cmd_ui",
199
+ keep_open: false,
200
+ help: false,
201
+ },
202
+ CommandHint {
203
+ insert: "theme dark",
204
+ display: "/theme dark",
205
+ desc_key: "cmd_theme",
206
+ keep_open: false,
207
+ help: true,
208
+ },
209
+ CommandHint {
210
+ insert: "theme light",
211
+ display: "/theme light",
212
+ desc_key: "cmd_theme",
213
+ keep_open: false,
214
+ help: false,
215
+ },
216
+ CommandHint {
217
+ insert: "source bank",
218
+ display: "/source bank",
219
+ desc_key: "cmd_source",
220
+ keep_open: false,
221
+ help: true,
222
+ },
223
+ CommandHint {
224
+ insert: "source ai",
225
+ display: "/source ai",
226
+ desc_key: "cmd_source",
227
+ keep_open: false,
228
+ help: false,
229
+ },
230
+ CommandHint {
231
+ insert: "help",
232
+ display: "/help",
233
+ desc_key: "cmd_help",
234
+ keep_open: false,
235
+ help: true,
236
+ },
237
+ CommandHint {
238
+ insert: "exit",
239
+ display: "/exit",
240
+ desc_key: "cmd_exit",
241
+ keep_open: false,
242
+ help: true,
243
+ },
244
+ ];
71
245
 
72
246
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
73
247
  enum Focus {
@@ -85,6 +259,7 @@ pub struct PracticodeApp {
85
259
  editor: TextEditor,
86
260
  command: String,
87
261
  command_cursor: usize,
262
+ command_palette_cursor: usize,
88
263
  output: String,
89
264
  output_is_markdown: bool,
90
265
  show_output: bool,
@@ -121,6 +296,7 @@ impl PracticodeApp {
121
296
  editor: TextEditor::default(),
122
297
  command: String::new(),
123
298
  command_cursor: 0,
299
+ command_palette_cursor: 0,
124
300
  output: String::new(),
125
301
  output_is_markdown: false,
126
302
  show_output: false,
@@ -174,6 +350,10 @@ impl PracticodeApp {
174
350
  self.command_cursor
175
351
  }
176
352
 
353
+ pub fn handle_key_for_test(&mut self, key: KeyEvent) -> Result<()> {
354
+ self.handle_key(key)
355
+ }
356
+
177
357
  pub fn busy_label(&self) -> &str {
178
358
  &self.busy_label
179
359
  }
@@ -201,7 +381,10 @@ impl PracticodeApp {
201
381
  &self.problem,
202
382
  &self.state.settings.ui_language,
203
383
  )))
204
- .block(Self::block("Problem", self.state.settings.theme == "light"))
384
+ .block(Self::block(
385
+ ui_text(&self.state.settings.ui_language, "problem"),
386
+ self.state.settings.theme == "light",
387
+ ))
205
388
  .wrap(Wrap { trim: false });
206
389
  frame.render_widget(problem, body[0]);
207
390
 
@@ -214,7 +397,10 @@ impl PracticodeApp {
214
397
  self.output.clone()
215
398
  };
216
399
  let output = Paragraph::new(text)
217
- .block(Self::block("Output", self.state.settings.theme == "light"))
400
+ .block(Self::block(
401
+ ui_text(&self.state.settings.ui_language, "output"),
402
+ self.state.settings.theme == "light",
403
+ ))
218
404
  .wrap(Wrap { trim: false });
219
405
  frame.render_widget(output, body[1]);
220
406
  } else {
@@ -244,15 +430,56 @@ impl PracticodeApp {
244
430
  let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
245
431
  self.command.clone()
246
432
  } else {
247
- "/run, /next easy string problem, /ai hint, /help".to_string()
433
+ ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
248
434
  };
249
435
  let command = Paragraph::new(command_text)
250
- .block(Self::block("Command", self.state.settings.theme == "light"))
436
+ .block(Self::block(
437
+ ui_text(&self.state.settings.ui_language, "command"),
438
+ self.state.settings.theme == "light",
439
+ ))
251
440
  .wrap(Wrap { trim: false });
252
441
  frame.render_widget(command, vertical[2]);
442
+ self.draw_command_palette(frame, vertical[2]);
253
443
  self.set_terminal_cursor(frame, body[1], vertical[2]);
254
444
  }
255
445
 
446
+ fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
447
+ let suggestions = self.command_suggestions();
448
+ if suggestions.is_empty() || command_area.y < 3 {
449
+ return;
450
+ }
451
+ let height = ((suggestions.len() + 3) as u16).min(10).min(command_area.y);
452
+ let area = Rect::new(
453
+ command_area.x,
454
+ command_area.y - height,
455
+ command_area.width,
456
+ height,
457
+ );
458
+ let selected = self.command_palette_cursor.min(suggestions.len() - 1);
459
+ let mut lines = suggestions
460
+ .iter()
461
+ .enumerate()
462
+ .take(height.saturating_sub(2) as usize)
463
+ .map(|(index, hint)| {
464
+ let marker = if index == selected { ">" } else { " " };
465
+ format!(
466
+ "{marker} {:<16} {}",
467
+ hint.display,
468
+ ui_text(&self.state.settings.ui_language, hint.desc_key)
469
+ )
470
+ })
471
+ .collect::<Vec<_>>();
472
+ lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
473
+ frame.render_widget(Clear, area);
474
+ frame.render_widget(
475
+ Paragraph::new(lines.join("\n")).block(Self::block(
476
+ ui_text(&self.state.settings.ui_language, "commands"),
477
+ self.state.settings.theme == "light",
478
+ )),
479
+ area,
480
+ );
481
+ }
482
+
256
483
  fn block(title: &str, light: bool) -> Block<'_> {
257
484
  Block::default()
258
485
  .borders(Borders::ALL)
@@ -277,14 +504,18 @@ impl PracticodeApp {
277
504
  KeyCode::Esc => {
278
505
  self.command.clear();
279
506
  self.command_cursor = 0;
507
+ self.command_palette_cursor = 0;
280
508
  self.focus = Focus::None;
281
509
  }
282
510
  KeyCode::Enter => {
283
- let value = self.command.trim().to_string();
284
- self.command.clear();
285
- self.command_cursor = 0;
286
- self.focus = Focus::None;
287
- self.submit_command(&value)?;
511
+ if !self.accept_command_palette()? {
512
+ let value = self.command.trim().to_string();
513
+ self.command.clear();
514
+ self.command_cursor = 0;
515
+ self.command_palette_cursor = 0;
516
+ self.focus = Focus::None;
517
+ self.submit_command(&value)?;
518
+ }
288
519
  }
289
520
  KeyCode::Backspace => self.delete_command_before_cursor(),
290
521
  KeyCode::Delete => self.delete_command_at_cursor(),
@@ -292,11 +523,14 @@ impl PracticodeApp {
292
523
  KeyCode::Right => {
293
524
  self.command_cursor = (self.command_cursor + 1).min(char_len(&self.command));
294
525
  }
526
+ KeyCode::Up => self.move_command_palette(-1),
527
+ KeyCode::Down => self.move_command_palette(1),
295
528
  KeyCode::Home => self.command_cursor = 0,
296
529
  KeyCode::End => self.command_cursor = char_len(&self.command),
297
530
  KeyCode::Char('?') if self.command.trim().is_empty() || self.command.trim() == "/" => {
298
531
  self.command.clear();
299
532
  self.command_cursor = 0;
533
+ self.command_palette_cursor = 0;
300
534
  self.focus = Focus::None;
301
535
  self.handle_command("help")?;
302
536
  }
@@ -389,6 +623,7 @@ impl PracticodeApp {
389
623
  self.command.push('/');
390
624
  self.command_cursor = 1;
391
625
  }
626
+ self.command_palette_cursor = 0;
392
627
  self.focus = Focus::Command;
393
628
  }
394
629
 
@@ -404,7 +639,7 @@ impl PracticodeApp {
404
639
  fn handle_command(&mut self, value: &str) -> Result<()> {
405
640
  if value.is_empty() || matches!(value, "help" | "h" | "?") {
406
641
  self.list_cursor = None;
407
- self.write_output(HELP);
642
+ self.write_output(&self.help_text());
408
643
  return Ok(());
409
644
  }
410
645
  if value.starts_with("vim") {
@@ -428,7 +663,7 @@ impl PracticodeApp {
428
663
  "lang" if arg.is_empty() => self.action_cycle_language()?,
429
664
  "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
430
665
  "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
431
- "ui" if UI_LANGUAGES.contains(&arg) => self.set_ui_language(arg)?,
666
+ "ui" => self.set_ui_language(&normalize_ui_language(arg))?,
432
667
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
433
668
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
434
669
  "source" | "next-source" if arg.is_empty() => {
@@ -640,9 +875,9 @@ impl PracticodeApp {
640
875
  }
641
876
 
642
877
  fn set_ui_language(&mut self, language: &str) -> Result<()> {
643
- self.state.settings.ui_language = language.to_string();
878
+ self.state.settings.ui_language = normalize_ui_language(language);
644
879
  save_state(&self.root, &self.state)?;
645
- self.write_text_output(&format!("UI language: {language}"));
880
+ self.write_text_output(&format!("UI language: {}", self.state.settings.ui_language));
646
881
  Ok(())
647
882
  }
648
883
 
@@ -742,6 +977,7 @@ impl PracticodeApp {
742
977
  let byte = byte_index(&self.command, self.command_cursor);
743
978
  self.command.insert(byte, char);
744
979
  self.command_cursor += 1;
980
+ self.command_palette_cursor = 0;
745
981
  self.normalize_command_input();
746
982
  }
747
983
 
@@ -753,6 +989,7 @@ impl PracticodeApp {
753
989
  let end = byte_index(&self.command, self.command_cursor);
754
990
  self.command.replace_range(start..end, "");
755
991
  self.command_cursor -= 1;
992
+ self.command_palette_cursor = 0;
756
993
  self.normalize_command_input();
757
994
  }
758
995
 
@@ -763,9 +1000,55 @@ impl PracticodeApp {
763
1000
  let start = byte_index(&self.command, self.command_cursor);
764
1001
  let end = byte_index(&self.command, self.command_cursor + 1);
765
1002
  self.command.replace_range(start..end, "");
1003
+ self.command_palette_cursor = 0;
766
1004
  self.normalize_command_input();
767
1005
  }
768
1006
 
1007
+ fn command_suggestions(&self) -> Vec<&'static CommandHint> {
1008
+ if self.focus != Focus::Command {
1009
+ return Vec::new();
1010
+ }
1011
+ let Some(query) = self.command.trim_start().strip_prefix('/') else {
1012
+ return Vec::new();
1013
+ };
1014
+ let query = query.to_lowercase();
1015
+ COMMAND_HINTS
1016
+ .iter()
1017
+ .filter(|hint| hint.insert.starts_with(query.trim_start()))
1018
+ .take(7)
1019
+ .collect()
1020
+ }
1021
+
1022
+ fn move_command_palette(&mut self, delta: isize) {
1023
+ let len = self.command_suggestions().len();
1024
+ if len == 0 {
1025
+ return;
1026
+ }
1027
+ let cursor = self.command_palette_cursor as isize;
1028
+ self.command_palette_cursor = ((cursor + delta).rem_euclid(len as isize)) as usize;
1029
+ }
1030
+
1031
+ fn accept_command_palette(&mut self) -> Result<bool> {
1032
+ let suggestions = self.command_suggestions();
1033
+ if suggestions.is_empty() {
1034
+ return Ok(false);
1035
+ }
1036
+ let hint = suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1037
+ if hint.keep_open {
1038
+ self.command = format!("/{}", hint.insert);
1039
+ self.command_cursor = char_len(&self.command);
1040
+ self.command_palette_cursor = 0;
1041
+ return Ok(true);
1042
+ }
1043
+ let value = hint.insert;
1044
+ self.command.clear();
1045
+ self.command_cursor = 0;
1046
+ self.command_palette_cursor = 0;
1047
+ self.focus = Focus::None;
1048
+ self.submit_command(value)?;
1049
+ Ok(true)
1050
+ }
1051
+
769
1052
  fn normalize_command_input(&mut self) {
770
1053
  let normalized = compose_hangul_jamo(&self.command);
771
1054
  if normalized == self.command {
@@ -984,14 +1267,34 @@ impl PracticodeApp {
984
1267
  }
985
1268
 
986
1269
  fn mode_hint(&self) -> &'static str {
1270
+ let lang = &self.state.settings.ui_language;
987
1271
  match (self.focus, self.list_cursor.is_some(), self.show_output) {
988
- (Focus::Command, _, _) => "Enter submit | Esc cancel",
989
- (_, true, _) => "up/down move | Enter open | Esc close",
990
- (_, _, true) => "Esc code | / command | ? help",
991
- (Focus::Code, _, _) => "Esc then / command",
992
- _ => "/ command | ? help",
1272
+ (Focus::Command, _, _) => ui_text(lang, "hint_command"),
1273
+ (_, true, _) => ui_text(lang, "hint_list"),
1274
+ (_, _, true) => ui_text(lang, "hint_output"),
1275
+ (Focus::Code, _, _) => ui_text(lang, "hint_code"),
1276
+ _ => ui_text(lang, "hint_idle"),
993
1277
  }
994
1278
  }
1279
+
1280
+ fn help_text(&self) -> String {
1281
+ let lang = &self.state.settings.ui_language;
1282
+ let commands = COMMAND_HINTS
1283
+ .iter()
1284
+ .filter(|hint| hint.help)
1285
+ .map(|hint| format!("- `{}` {}", hint.display, ui_text(lang, hint.desc_key)))
1286
+ .collect::<Vec<_>>()
1287
+ .join("\n");
1288
+ format!(
1289
+ "# {}\n\n## {}\n\n1. Type code in the right pane.\n2. Press `Esc`, then choose `/run` from the command palette.\n3. Use `/next` when it passes.\n\n## {}\n\n{}\n\n## {}\n\n- `/` opens the command palette outside the editor.\n- `↑/↓` selects a command and `Enter` accepts it.\n- `Esc` cancels the command palette or leaves output.\n\n## {}\n\n- stdout is shown when a case fails.\n- stderr is shown without affecting the expected stdout.",
1290
+ ui_text(lang, "help_title"),
1291
+ ui_text(lang, "daily_loop"),
1292
+ ui_text(lang, "commands"),
1293
+ commands,
1294
+ ui_text(lang, "keys"),
1295
+ ui_text(lang, "debug_prints"),
1296
+ )
1297
+ }
995
1298
  }
996
1299
 
997
1300
  #[derive(Clone, Debug)]