practicode 0.1.0

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 ADDED
@@ -0,0 +1,1173 @@
1
+ use crate::{
2
+ ai::{append_problem_note, read_problem_notes, run_ai_next, run_ai_prompt},
3
+ core::{
4
+ AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
5
+ UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
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,
9
+ },
10
+ text::{
11
+ byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
12
+ },
13
+ };
14
+ use anyhow::Result;
15
+ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
16
+ use ratatui::{
17
+ DefaultTerminal, Frame,
18
+ layout::{Constraint, Direction, Layout, Position, Rect},
19
+ style::{Color, Modifier, Style},
20
+ widgets::{Block, Borders, Paragraph, Wrap},
21
+ };
22
+ use std::{
23
+ collections::HashMap,
24
+ fs,
25
+ path::PathBuf,
26
+ sync::mpsc::{self, Receiver},
27
+ thread,
28
+ time::Duration,
29
+ };
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
67
+
68
+ - stdout prints are shown when a case fails
69
+ - stderr prints are shown without affecting the expected stdout
70
+ "#;
71
+
72
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
73
+ enum Focus {
74
+ Code,
75
+ Command,
76
+ Output,
77
+ None,
78
+ }
79
+
80
+ pub struct PracticodeApp {
81
+ root: PathBuf,
82
+ bank: Vec<Problem>,
83
+ state: AppState,
84
+ problem: Problem,
85
+ editor: TextEditor,
86
+ command: String,
87
+ command_cursor: usize,
88
+ output: String,
89
+ output_is_markdown: bool,
90
+ show_output: bool,
91
+ focus: Focus,
92
+ list_cursor: Option<usize>,
93
+ busy_label: String,
94
+ busy_body: String,
95
+ busy_frame: usize,
96
+ task_rx: Option<Receiver<TaskResult>>,
97
+ should_quit: bool,
98
+ }
99
+
100
+ enum TaskResult {
101
+ AiPrompt(String),
102
+ Next {
103
+ output: String,
104
+ old_problem: String,
105
+ force: bool,
106
+ },
107
+ }
108
+
109
+ impl PracticodeApp {
110
+ pub fn new(root: PathBuf) -> Result<Self> {
111
+ let bank = load_bank(&root)?;
112
+ let state = load_state(&root, &bank)?;
113
+ let problem = problem_by_id(&bank, &state.current_problem)
114
+ .cloned()
115
+ .unwrap_or_else(|| bank[0].clone());
116
+ let mut app = Self {
117
+ root,
118
+ bank,
119
+ state,
120
+ problem,
121
+ editor: TextEditor::default(),
122
+ command: String::new(),
123
+ command_cursor: 0,
124
+ output: String::new(),
125
+ output_is_markdown: false,
126
+ show_output: false,
127
+ focus: Focus::Code,
128
+ list_cursor: None,
129
+ busy_label: String::new(),
130
+ busy_body: String::new(),
131
+ busy_frame: 0,
132
+ task_rx: None,
133
+ should_quit: false,
134
+ };
135
+ app.load_code_editor()?;
136
+ Ok(app)
137
+ }
138
+
139
+ pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
140
+ while !self.should_quit {
141
+ terminal.draw(|frame| self.draw(frame))?;
142
+ self.check_task();
143
+ if event::poll(Duration::from_millis(100))?
144
+ && let Event::Key(key) = event::read()?
145
+ && key.kind != KeyEventKind::Release
146
+ {
147
+ self.handle_key(key)?;
148
+ }
149
+ if !self.busy_label.is_empty() {
150
+ self.busy_frame = (self.busy_frame + 1) % 4;
151
+ }
152
+ }
153
+ self.save_code().ok();
154
+ Ok(())
155
+ }
156
+
157
+ pub fn handle_command_for_test(&mut self, value: &str) -> Result<()> {
158
+ self.handle_command(value)
159
+ }
160
+
161
+ pub fn focus_command_for_test(&mut self) {
162
+ self.focus_command();
163
+ }
164
+
165
+ pub fn insert_command_char_for_test(&mut self, char: char) {
166
+ self.insert_command_char(char);
167
+ }
168
+
169
+ pub fn command_text(&self) -> &str {
170
+ &self.command
171
+ }
172
+
173
+ pub fn command_cursor(&self) -> usize {
174
+ self.command_cursor
175
+ }
176
+
177
+ pub fn busy_label(&self) -> &str {
178
+ &self.busy_label
179
+ }
180
+
181
+ pub fn has_task(&self) -> bool {
182
+ self.task_rx.is_some()
183
+ }
184
+
185
+ fn draw(&mut self, frame: &mut Frame) {
186
+ let size = frame.area();
187
+ let vertical = Layout::default()
188
+ .direction(Direction::Vertical)
189
+ .constraints([
190
+ Constraint::Min(1),
191
+ Constraint::Length(1),
192
+ Constraint::Length(3),
193
+ ])
194
+ .split(size);
195
+ let body = Layout::default()
196
+ .direction(Direction::Horizontal)
197
+ .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
198
+ .split(vertical[0]);
199
+
200
+ let problem = Paragraph::new(render_markdown_plain(&render_problem(
201
+ &self.problem,
202
+ &self.state.settings.ui_language,
203
+ )))
204
+ .block(Self::block("Problem", self.state.settings.theme == "light"))
205
+ .wrap(Wrap { trim: false });
206
+ frame.render_widget(problem, body[0]);
207
+
208
+ if self.show_output {
209
+ let text = if !self.busy_label.is_empty() {
210
+ format!("{}{}", self.busy_body, ".".repeat(self.busy_frame))
211
+ } else if self.output_is_markdown {
212
+ render_markdown_plain(&self.output)
213
+ } else {
214
+ self.output.clone()
215
+ };
216
+ let output = Paragraph::new(text)
217
+ .block(Self::block("Output", self.state.settings.theme == "light"))
218
+ .wrap(Wrap { trim: false });
219
+ frame.render_widget(output, body[1]);
220
+ } else {
221
+ let code = self
222
+ .editor
223
+ .visible_text(body[1].height.saturating_sub(2) as usize);
224
+ let title = format!("solution.{}", ext_for(&self.state.settings.language));
225
+ let code = Paragraph::new(code)
226
+ .block(Self::block(&title, self.state.settings.theme == "light"));
227
+ frame.render_widget(code, body[1]);
228
+ }
229
+
230
+ let status =
231
+ Paragraph::new(self.status_text()).style(if self.state.settings.theme == "light" {
232
+ Style::default()
233
+ .fg(Color::Blue)
234
+ .bg(Color::Rgb(219, 234, 254))
235
+ .add_modifier(Modifier::BOLD)
236
+ } else {
237
+ Style::default()
238
+ .fg(Color::Rgb(200, 211, 245))
239
+ .bg(Color::Rgb(21, 32, 51))
240
+ .add_modifier(Modifier::BOLD)
241
+ });
242
+ frame.render_widget(status, vertical[1]);
243
+
244
+ let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
245
+ self.command.clone()
246
+ } else {
247
+ "/run, /next easy string problem, /ai hint, /help".to_string()
248
+ };
249
+ let command = Paragraph::new(command_text)
250
+ .block(Self::block("Command", self.state.settings.theme == "light"))
251
+ .wrap(Wrap { trim: false });
252
+ frame.render_widget(command, vertical[2]);
253
+ self.set_terminal_cursor(frame, body[1], vertical[2]);
254
+ }
255
+
256
+ fn block(title: &str, light: bool) -> Block<'_> {
257
+ Block::default()
258
+ .borders(Borders::ALL)
259
+ .title(title)
260
+ .border_style(if light {
261
+ Style::default().fg(Color::Blue)
262
+ } else {
263
+ Style::default().fg(Color::Cyan)
264
+ })
265
+ }
266
+
267
+ fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
268
+ match self.focus {
269
+ Focus::Command => self.handle_command_key(key),
270
+ Focus::Code => self.handle_code_key(key),
271
+ _ => self.handle_global_key(key),
272
+ }
273
+ }
274
+
275
+ fn handle_command_key(&mut self, key: KeyEvent) -> Result<()> {
276
+ match key.code {
277
+ KeyCode::Esc => {
278
+ self.command.clear();
279
+ self.command_cursor = 0;
280
+ self.focus = Focus::None;
281
+ }
282
+ 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)?;
288
+ }
289
+ KeyCode::Backspace => self.delete_command_before_cursor(),
290
+ KeyCode::Delete => self.delete_command_at_cursor(),
291
+ KeyCode::Left => self.command_cursor = self.command_cursor.saturating_sub(1),
292
+ KeyCode::Right => {
293
+ self.command_cursor = (self.command_cursor + 1).min(char_len(&self.command));
294
+ }
295
+ KeyCode::Home => self.command_cursor = 0,
296
+ KeyCode::End => self.command_cursor = char_len(&self.command),
297
+ KeyCode::Char('?') if self.command.trim().is_empty() || self.command.trim() == "/" => {
298
+ self.command.clear();
299
+ self.command_cursor = 0;
300
+ self.focus = Focus::None;
301
+ self.handle_command("help")?;
302
+ }
303
+ KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
304
+ self.insert_command_char(char);
305
+ }
306
+ _ => {}
307
+ }
308
+ Ok(())
309
+ }
310
+
311
+ fn handle_code_key(&mut self, key: KeyEvent) -> Result<()> {
312
+ match key.code {
313
+ KeyCode::Esc => self.focus = Focus::None,
314
+ KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
315
+ self.editor.insert_char(char);
316
+ self.save_code()?;
317
+ }
318
+ KeyCode::Enter => {
319
+ self.editor.insert_newline();
320
+ self.save_code()?;
321
+ }
322
+ KeyCode::Backspace => {
323
+ self.editor.backspace();
324
+ self.save_code()?;
325
+ }
326
+ KeyCode::Delete => {
327
+ self.editor.delete();
328
+ self.save_code()?;
329
+ }
330
+ KeyCode::Tab => {
331
+ for _ in 0..4 {
332
+ self.editor.insert_char(' ');
333
+ }
334
+ self.save_code()?;
335
+ }
336
+ KeyCode::Left => self.editor.move_left(),
337
+ KeyCode::Right => self.editor.move_right(),
338
+ KeyCode::Up => self.editor.move_up(),
339
+ KeyCode::Down => self.editor.move_down(),
340
+ _ => {}
341
+ }
342
+ Ok(())
343
+ }
344
+
345
+ fn handle_global_key(&mut self, key: KeyEvent) -> Result<()> {
346
+ if let Some(cursor) = self.list_cursor {
347
+ match key.code {
348
+ KeyCode::Up | KeyCode::Char('k') => self.move_list_cursor(-1),
349
+ KeyCode::Down | KeyCode::Char('j') => self.move_list_cursor(1),
350
+ KeyCode::Enter => self.open_selected_problem()?,
351
+ KeyCode::Esc => {
352
+ self.list_cursor = None;
353
+ self.write_text_output("Closed list.");
354
+ }
355
+ _ => {
356
+ self.list_cursor = Some(cursor);
357
+ self.handle_global_shortcut(key)?;
358
+ }
359
+ }
360
+ return Ok(());
361
+ }
362
+ if key.code == KeyCode::Esc && self.show_output {
363
+ self.show_output = false;
364
+ self.focus = Focus::Code;
365
+ return Ok(());
366
+ }
367
+ self.handle_global_shortcut(key)
368
+ }
369
+
370
+ fn handle_global_shortcut(&mut self, key: KeyEvent) -> Result<()> {
371
+ match key.code {
372
+ KeyCode::Char('/') => self.focus_command(),
373
+ KeyCode::Char('?') => self.handle_command("help")?,
374
+ KeyCode::Char('r') => self.action_run()?,
375
+ KeyCode::Char('n') => self.action_next("")?,
376
+ KeyCode::Char('p') => self.action_previous()?,
377
+ KeyCode::Char('g') => self.action_give_up()?,
378
+ KeyCode::Char('e') => self.action_edit()?,
379
+ KeyCode::Char('l') => self.action_cycle_language()?,
380
+ KeyCode::Char('u') => self.action_toggle_ui_language()?,
381
+ KeyCode::Char('q') => self.should_quit = true,
382
+ _ => {}
383
+ }
384
+ Ok(())
385
+ }
386
+
387
+ fn focus_command(&mut self) {
388
+ if self.command.is_empty() {
389
+ self.command.push('/');
390
+ self.command_cursor = 1;
391
+ }
392
+ self.focus = Focus::Command;
393
+ }
394
+
395
+ fn submit_command(&mut self, value: &str) -> Result<()> {
396
+ let value = value
397
+ .trim()
398
+ .strip_prefix('/')
399
+ .unwrap_or(value.trim())
400
+ .trim();
401
+ self.handle_command(value)
402
+ }
403
+
404
+ fn handle_command(&mut self, value: &str) -> Result<()> {
405
+ if value.is_empty() || matches!(value, "help" | "h" | "?") {
406
+ self.list_cursor = None;
407
+ self.write_output(HELP);
408
+ return Ok(());
409
+ }
410
+ if value.starts_with("vim") {
411
+ self.list_cursor = None;
412
+ self.write_text_output("The code editor is already open on the right.");
413
+ return Ok(());
414
+ }
415
+ let (command, arg) = value.split_once(char::is_whitespace).unwrap_or((value, ""));
416
+ let arg = arg.trim();
417
+ if command != "list" {
418
+ self.list_cursor = None;
419
+ }
420
+ match command {
421
+ "run" | "r" => self.action_run()?,
422
+ "edit" | "e" => self.action_edit()?,
423
+ "next" | "n" => self.action_next(arg)?,
424
+ "prev" | "previous" | "p" => self.action_previous()?,
425
+ "giveup" | "give" | "g" => self.action_give_up()?,
426
+ "list" => self.start_problem_list(),
427
+ "open" | "o" if !arg.is_empty() => self.open_problem(arg)?,
428
+ "lang" if arg.is_empty() => self.action_cycle_language()?,
429
+ "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
430
+ "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
431
+ "ui" if UI_LANGUAGES.contains(&arg) => self.set_ui_language(arg)?,
432
+ "theme" if arg.is_empty() => self.action_toggle_theme()?,
433
+ "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
434
+ "source" | "next-source" if arg.is_empty() => {
435
+ self.write_text_output(&format!(
436
+ "Next source: {}",
437
+ self.state.settings.next_source
438
+ ));
439
+ }
440
+ "source" | "next-source" if matches!(arg, "bank" | "ai") => {
441
+ self.state.settings.next_source = normalize_next_source(arg);
442
+ save_state(&self.root, &self.state)?;
443
+ self.write_text_output(&format!(
444
+ "Next source: {}",
445
+ self.state.settings.next_source
446
+ ));
447
+ }
448
+ "ai-next-command" if !arg.is_empty() => {
449
+ self.state.settings.ai_next_command = arg.to_string();
450
+ self.state.settings.next_source = "ai".to_string();
451
+ save_state(&self.root, &self.state)?;
452
+ self.write_text_output("AI next command saved.");
453
+ }
454
+ "provider" | "ai-provider" if arg.is_empty() => {
455
+ self.write_text_output(&format!(
456
+ "AI provider: {}",
457
+ self.state.settings.ai_provider
458
+ ));
459
+ }
460
+ "provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
461
+ self.state.settings.ai_provider = normalize_ai_provider(arg);
462
+ save_state(&self.root, &self.state)?;
463
+ self.write_text_output(&format!(
464
+ "AI provider: {}",
465
+ self.state.settings.ai_provider
466
+ ));
467
+ }
468
+ "model" if arg.is_empty() => {
469
+ self.write_text_output(&format!("AI model: {}", self.state.settings.ai_model));
470
+ }
471
+ "model" => {
472
+ self.state.settings.ai_model = arg.to_string();
473
+ save_state(&self.root, &self.state)?;
474
+ self.write_text_output(&format!("AI model: {arg}"));
475
+ }
476
+ "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
477
+ "note" if !arg.is_empty() => self.append_note(arg)?,
478
+ "note" | "notes" => self.show_notes()?,
479
+ "exit" | "quit" | "q" => self.should_quit = true,
480
+ _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
481
+ }
482
+ Ok(())
483
+ }
484
+
485
+ fn action_edit(&mut self) -> Result<()> {
486
+ self.load_code_editor()?;
487
+ self.show_output = false;
488
+ self.focus = Focus::Code;
489
+ Ok(())
490
+ }
491
+
492
+ fn action_run(&mut self) -> Result<()> {
493
+ self.save_code()?;
494
+ let result = judge(&self.root, &self.problem, &self.state.settings);
495
+ if result.passed {
496
+ record_pass(&self.root, &self.problem, &mut self.state)?;
497
+ }
498
+ let headline = format!(
499
+ "{} {}/{}",
500
+ if result.passed { "PASS" } else { "FAIL" },
501
+ result.passed_cases,
502
+ result.total_cases
503
+ );
504
+ let next_step = if result.passed {
505
+ "Next: /next"
506
+ } else {
507
+ "Fix code, then /run"
508
+ };
509
+ self.write_text_output(&format!("{headline}\n{}\n\n{next_step}", result.output));
510
+ Ok(())
511
+ }
512
+
513
+ fn action_next(&mut self, request: &str) -> Result<()> {
514
+ let request = request.trim();
515
+ let old_problem = self.state.current_problem.clone();
516
+ if !request.is_empty() {
517
+ self.start_next_problem(old_problem, true, request.to_string());
518
+ return Ok(());
519
+ }
520
+ if self.state.settings.next_source == "ai" {
521
+ self.start_next_problem(old_problem, false, String::new());
522
+ return Ok(());
523
+ }
524
+ if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
525
+ self.problem = problem;
526
+ self.load_code_editor()?;
527
+ self.show_output = false;
528
+ self.focus = Focus::Code;
529
+ return Ok(());
530
+ }
531
+ self.start_next_problem(old_problem, true, String::new());
532
+ Ok(())
533
+ }
534
+
535
+ fn start_next_problem(&mut self, old_problem: String, force: bool, request: String) {
536
+ if self.task_rx.is_some() {
537
+ self.write_text_output("Already busy.");
538
+ return;
539
+ }
540
+ self.start_busy("next", "Generating next problem");
541
+ let root = self.root.clone();
542
+ let state = self.state.clone();
543
+ let (tx, rx) = mpsc::channel();
544
+ thread::spawn(move || {
545
+ let output = run_ai_next(&root, &state, force, &request);
546
+ let _ = tx.send(TaskResult::Next {
547
+ output,
548
+ old_problem,
549
+ force,
550
+ });
551
+ });
552
+ self.task_rx = Some(rx);
553
+ }
554
+
555
+ fn finish_next_problem(
556
+ &mut self,
557
+ output: String,
558
+ old_problem: String,
559
+ force: bool,
560
+ ) -> Result<()> {
561
+ if self.state.settings.next_source == "ai" || force {
562
+ self.bank = load_bank(&self.root)?;
563
+ self.state = load_state(&self.root, &self.bank)?;
564
+ }
565
+ self.problem = problem_by_id(&self.bank, &self.state.current_problem)
566
+ .cloned()
567
+ .unwrap_or_else(|| self.bank[0].clone());
568
+ if self.state.current_problem == old_problem {
569
+ if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
570
+ self.problem = problem;
571
+ } else {
572
+ self.write_text_output(&format!(
573
+ "{}{}No next problem is available yet.",
574
+ if output.is_empty() { "" } else { &output },
575
+ if output.is_empty() { "" } else { "\n\n" }
576
+ ));
577
+ return Ok(());
578
+ }
579
+ }
580
+ self.load_code_editor()?;
581
+ self.show_output = false;
582
+ self.focus = Focus::Code;
583
+ Ok(())
584
+ }
585
+
586
+ fn action_previous(&mut self) -> Result<()> {
587
+ let old_problem = self.state.current_problem.clone();
588
+ self.problem = previous_problem(&self.root, &self.bank, &mut self.state)?;
589
+ if self.state.current_problem == old_problem {
590
+ self.write_text_output("Already at the first known problem.");
591
+ } else {
592
+ self.load_code_editor()?;
593
+ self.show_output = false;
594
+ self.focus = Focus::Code;
595
+ }
596
+ Ok(())
597
+ }
598
+
599
+ fn action_give_up(&mut self) -> Result<()> {
600
+ let answer = give_up(&self.root, &self.problem, &mut self.state)?;
601
+ let language = normalize_language(&self.state.settings.language);
602
+ self.write_output(&format!(
603
+ "Answer for {language}:\n\n```{language}\n{}\n```",
604
+ answer.trim_end()
605
+ ));
606
+ Ok(())
607
+ }
608
+
609
+ fn action_cycle_language(&mut self) -> Result<()> {
610
+ let current = LANGUAGES
611
+ .iter()
612
+ .position(|language| language == &self.state.settings.language)
613
+ .unwrap_or(0);
614
+ self.set_language(LANGUAGES[(current + 1) % LANGUAGES.len()])
615
+ }
616
+
617
+ fn action_toggle_ui_language(&mut self) -> Result<()> {
618
+ let current = UI_LANGUAGES
619
+ .iter()
620
+ .position(|language| language == &self.state.settings.ui_language)
621
+ .unwrap_or(0);
622
+ self.set_ui_language(UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()])
623
+ }
624
+
625
+ fn action_toggle_theme(&mut self) -> Result<()> {
626
+ let current = THEMES
627
+ .iter()
628
+ .position(|theme| theme == &self.state.settings.theme)
629
+ .unwrap_or(0);
630
+ self.set_theme(THEMES[(current + 1) % THEMES.len()])
631
+ }
632
+
633
+ fn set_language(&mut self, language: &str) -> Result<()> {
634
+ self.state.settings.language = language.to_string();
635
+ save_state(&self.root, &self.state)?;
636
+ self.load_code_editor()?;
637
+ self.show_output = false;
638
+ self.focus = Focus::Code;
639
+ Ok(())
640
+ }
641
+
642
+ fn set_ui_language(&mut self, language: &str) -> Result<()> {
643
+ self.state.settings.ui_language = language.to_string();
644
+ save_state(&self.root, &self.state)?;
645
+ self.write_text_output(&format!("UI language: {language}"));
646
+ Ok(())
647
+ }
648
+
649
+ fn set_theme(&mut self, theme: &str) -> Result<()> {
650
+ self.state.settings.theme = theme.to_string();
651
+ save_state(&self.root, &self.state)?;
652
+ self.write_text_output(&format!("Theme: {theme}"));
653
+ Ok(())
654
+ }
655
+
656
+ fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
657
+ if self.task_rx.is_some() {
658
+ self.write_text_output("Already busy.");
659
+ return Ok(());
660
+ }
661
+ self.save_code()?;
662
+ let label = normalize_ai_provider(&self.state.settings.ai_provider);
663
+ self.start_busy("ai", &format!("{label} is thinking"));
664
+ let root = self.root.clone();
665
+ let problem = self.problem.clone();
666
+ let settings = self.state.settings.clone();
667
+ let prompt = prompt.to_string();
668
+ let (tx, rx) = mpsc::channel();
669
+ thread::spawn(move || {
670
+ let output = run_ai_prompt(&root, &problem, &settings, &prompt);
671
+ let _ = tx.send(TaskResult::AiPrompt(output));
672
+ });
673
+ self.task_rx = Some(rx);
674
+ Ok(())
675
+ }
676
+
677
+ fn check_task(&mut self) {
678
+ let task = self.task_rx.as_ref().and_then(|rx| rx.try_recv().ok());
679
+ if let Some(task) = task {
680
+ self.task_rx = None;
681
+ self.stop_busy();
682
+ match task {
683
+ TaskResult::AiPrompt(output) => self.write_output(&output),
684
+ TaskResult::Next {
685
+ output,
686
+ old_problem,
687
+ force,
688
+ } => {
689
+ if let Err(error) = self.finish_next_problem(output, old_problem, force) {
690
+ self.write_text_output(&format!("Next failed\n{error}"));
691
+ }
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ fn start_busy(&mut self, label: &str, body: &str) {
698
+ self.busy_label = label.to_string();
699
+ self.busy_body = body.to_string();
700
+ self.busy_frame = 0;
701
+ self.show_output = true;
702
+ self.focus = Focus::Output;
703
+ }
704
+
705
+ fn stop_busy(&mut self) {
706
+ self.busy_label.clear();
707
+ self.busy_body.clear();
708
+ self.busy_frame = 0;
709
+ }
710
+
711
+ fn write_output(&mut self, output: &str) {
712
+ self.output = output.to_string();
713
+ self.output_is_markdown = true;
714
+ self.show_output = true;
715
+ self.focus = Focus::Output;
716
+ }
717
+
718
+ fn write_text_output(&mut self, output: &str) {
719
+ self.output = output.trim_end().to_string();
720
+ self.output_is_markdown = false;
721
+ self.show_output = true;
722
+ self.focus = Focus::Output;
723
+ }
724
+
725
+ fn append_note(&mut self, note: &str) -> Result<()> {
726
+ append_problem_note(&self.root, note)?;
727
+ self.write_text_output(&format!("Problem note saved to {PROBLEM_NOTES_PATH}."));
728
+ Ok(())
729
+ }
730
+
731
+ fn show_notes(&mut self) -> Result<()> {
732
+ let notes = read_problem_notes(&self.root)?;
733
+ if notes.is_empty() {
734
+ self.write_text_output("No notes yet. Add one with /note <text>.");
735
+ } else {
736
+ self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
737
+ }
738
+ Ok(())
739
+ }
740
+
741
+ fn insert_command_char(&mut self, char: char) {
742
+ let byte = byte_index(&self.command, self.command_cursor);
743
+ self.command.insert(byte, char);
744
+ self.command_cursor += 1;
745
+ self.normalize_command_input();
746
+ }
747
+
748
+ fn delete_command_before_cursor(&mut self) {
749
+ if self.command_cursor == 0 {
750
+ return;
751
+ }
752
+ let start = byte_index(&self.command, self.command_cursor - 1);
753
+ let end = byte_index(&self.command, self.command_cursor);
754
+ self.command.replace_range(start..end, "");
755
+ self.command_cursor -= 1;
756
+ self.normalize_command_input();
757
+ }
758
+
759
+ fn delete_command_at_cursor(&mut self) {
760
+ if self.command_cursor >= char_len(&self.command) {
761
+ return;
762
+ }
763
+ let start = byte_index(&self.command, self.command_cursor);
764
+ let end = byte_index(&self.command, self.command_cursor + 1);
765
+ self.command.replace_range(start..end, "");
766
+ self.normalize_command_input();
767
+ }
768
+
769
+ fn normalize_command_input(&mut self) {
770
+ let normalized = compose_hangul_jamo(&self.command);
771
+ if normalized == self.command {
772
+ self.command_cursor = self.command_cursor.min(char_len(&self.command));
773
+ return;
774
+ }
775
+ let old_prefix = prefix(&self.command, self.command_cursor);
776
+ self.command = normalized;
777
+ self.command_cursor =
778
+ char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.command));
779
+ }
780
+
781
+ fn set_terminal_cursor(&self, frame: &mut Frame, code_area: Rect, command_area: Rect) {
782
+ match self.focus {
783
+ Focus::Command => {
784
+ let before = prefix(&self.command, self.command_cursor);
785
+ let x = command_area
786
+ .x
787
+ .saturating_add(1)
788
+ .saturating_add(display_width(&before) as u16)
789
+ .min(command_area.right().saturating_sub(2));
790
+ frame.set_cursor_position(Position::new(x, command_area.y.saturating_add(1)));
791
+ }
792
+ Focus::Code if !self.show_output => {
793
+ if let Some(position) = self.editor.cursor_position(code_area) {
794
+ frame.set_cursor_position(position);
795
+ }
796
+ }
797
+ _ => {}
798
+ }
799
+ }
800
+
801
+ fn load_code_editor(&mut self) -> Result<()> {
802
+ let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?;
803
+ let text = fs::read_to_string(path).unwrap_or_default();
804
+ self.editor.set_text(&text);
805
+ Ok(())
806
+ }
807
+
808
+ fn save_code(&self) -> Result<()> {
809
+ let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?;
810
+ fs::write(path, self.editor.text())?;
811
+ Ok(())
812
+ }
813
+
814
+ fn start_problem_list(&mut self) {
815
+ self.list_cursor = Some(self.current_problem_index());
816
+ self.write_text_output(&self.render_problem_list());
817
+ }
818
+
819
+ fn render_problem_list(&self) -> String {
820
+ let status_by_id = self
821
+ .state
822
+ .history
823
+ .iter()
824
+ .map(|item| (item.id.as_str(), item.status.as_str()))
825
+ .collect::<HashMap<_, _>>();
826
+ let cursor = self
827
+ .list_cursor
828
+ .unwrap_or_else(|| self.current_problem_index());
829
+ let mut lines = vec![
830
+ "Problems".to_string(),
831
+ String::new(),
832
+ " # ID Difficulty Status Code Title".to_string(),
833
+ ];
834
+ for (index, problem) in self.bank.iter().enumerate() {
835
+ let marker = if index == cursor { ">" } else { " " };
836
+ let current = if problem.id == self.problem.id {
837
+ "*"
838
+ } else {
839
+ " "
840
+ };
841
+ let title = localized(&problem.title, &self.state.settings.ui_language);
842
+ let code_status = self.submission_status(problem).0;
843
+ lines.push(format!(
844
+ "{marker} {current} {:>2} {:<18} {:<10} {:<10} {:<9} {title}",
845
+ index + 1,
846
+ problem.id,
847
+ problem.difficulty,
848
+ status_by_id
849
+ .get(problem.id.as_str())
850
+ .copied()
851
+ .unwrap_or("-"),
852
+ code_status,
853
+ ));
854
+ }
855
+ lines.push("\nup/down or j/k select | enter open | esc close".to_string());
856
+ lines.join("\n")
857
+ }
858
+
859
+ fn current_problem_index(&self) -> usize {
860
+ self.bank
861
+ .iter()
862
+ .position(|problem| problem.id == self.problem.id)
863
+ .unwrap_or(0)
864
+ }
865
+
866
+ fn move_list_cursor(&mut self, delta: isize) {
867
+ if self.bank.is_empty() {
868
+ return;
869
+ }
870
+ let cursor = self
871
+ .list_cursor
872
+ .unwrap_or_else(|| self.current_problem_index()) as isize;
873
+ let len = self.bank.len() as isize;
874
+ self.list_cursor = Some(((cursor + delta).rem_euclid(len)) as usize);
875
+ self.write_text_output(&self.render_problem_list());
876
+ }
877
+
878
+ fn open_selected_problem(&mut self) -> Result<()> {
879
+ if let Some(cursor) = self.list_cursor {
880
+ let problem_id = self.bank[cursor].id.clone();
881
+ self.list_cursor = None;
882
+ self.open_problem(&problem_id)?;
883
+ }
884
+ Ok(())
885
+ }
886
+
887
+ fn open_problem(&mut self, query: &str) -> Result<()> {
888
+ self.list_cursor = None;
889
+ let Some(problem) = self.find_problem(query).cloned() else {
890
+ self.write_text_output(&format!("Problem not found: {query}\nTry /list."));
891
+ return Ok(());
892
+ };
893
+ self.problem = problem;
894
+ self.state.current_problem = self.problem.id.clone();
895
+ if !self
896
+ .state
897
+ .history
898
+ .iter()
899
+ .any(|item| item.id == self.problem.id)
900
+ {
901
+ self.state.history.push(HistoryItem {
902
+ id: self.problem.id.clone(),
903
+ status: "assigned".to_string(),
904
+ });
905
+ }
906
+ save_state(&self.root, &self.state)?;
907
+ ensure_problem_files(&self.root, &self.problem)?;
908
+ self.load_code_editor()?;
909
+ self.show_output = false;
910
+ self.focus = Focus::Code;
911
+ Ok(())
912
+ }
913
+
914
+ fn find_problem(&self, query: &str) -> Option<&Problem> {
915
+ let needle = if query.trim().chars().all(|c| c.is_ascii_digit()) {
916
+ format!("{:03}", query.trim().parse::<usize>().ok()?)
917
+ } else {
918
+ query.trim().to_lowercase()
919
+ };
920
+ self.bank.iter().find(|problem| {
921
+ needle == problem.id.to_lowercase()
922
+ || needle == problem.slug.to_lowercase()
923
+ || problem.id.starts_with(&needle)
924
+ })
925
+ }
926
+
927
+ fn problem_status(&self, problem: &Problem) -> String {
928
+ if self.state.solved.contains(&problem.id) {
929
+ return "solved".to_string();
930
+ }
931
+ self.state
932
+ .history
933
+ .iter()
934
+ .rev()
935
+ .find(|item| item.id == problem.id)
936
+ .map(|item| item.status.clone())
937
+ .unwrap_or_else(|| "not_started".to_string())
938
+ }
939
+
940
+ fn submission_status(&self, problem: &Problem) -> (String, String) {
941
+ let language = normalize_language(&self.state.settings.language);
942
+ let path = self
943
+ .root
944
+ .join("submissions")
945
+ .join(&problem.id)
946
+ .join(format!("solution.{}", ext_for(&language)));
947
+ if !path.exists() {
948
+ return ("missing".to_string(), format!("({language})"));
949
+ }
950
+ let content = fs::read_to_string(&path).unwrap_or_default();
951
+ let relative = path.strip_prefix(&self.root).unwrap_or(&path).display();
952
+ if content == template_for(&language) {
953
+ ("template".to_string(), format!("({relative})"))
954
+ } else if content.trim().is_empty() {
955
+ ("empty".to_string(), format!("({relative})"))
956
+ } else {
957
+ ("written".to_string(), format!("({relative})"))
958
+ }
959
+ }
960
+
961
+ fn status_text(&self) -> String {
962
+ let code_status = self.submission_status(&self.problem).0;
963
+ format!(
964
+ " PRACTICODE | {} | {} | {} | {} | code:{} | {} | next:{} | ai:{}/{} | {} ",
965
+ self.problem.id,
966
+ self.problem.difficulty,
967
+ self.busy_status(),
968
+ self.problem_status(&self.problem),
969
+ code_status,
970
+ self.state.settings.language,
971
+ self.state.settings.next_source,
972
+ self.state.settings.ai_provider,
973
+ self.state.settings.ai_model,
974
+ self.mode_hint(),
975
+ )
976
+ }
977
+
978
+ fn busy_status(&self) -> String {
979
+ if self.busy_label.is_empty() {
980
+ "idle".to_string()
981
+ } else {
982
+ format!("busy:{}{}", self.busy_label, ".".repeat(self.busy_frame))
983
+ }
984
+ }
985
+
986
+ fn mode_hint(&self) -> &'static str {
987
+ 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",
993
+ }
994
+ }
995
+ }
996
+
997
+ #[derive(Clone, Debug)]
998
+ pub struct TextEditor {
999
+ lines: Vec<String>,
1000
+ row: usize,
1001
+ col: usize,
1002
+ scroll: usize,
1003
+ }
1004
+
1005
+ impl Default for TextEditor {
1006
+ fn default() -> Self {
1007
+ Self {
1008
+ lines: vec![String::new()],
1009
+ row: 0,
1010
+ col: 0,
1011
+ scroll: 0,
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ impl TextEditor {
1017
+ pub fn set_text(&mut self, text: &str) {
1018
+ self.lines = text.split('\n').map(str::to_string).collect();
1019
+ if text.ends_with('\n') {
1020
+ self.lines.pop();
1021
+ self.lines.push(String::new());
1022
+ }
1023
+ if self.lines.is_empty() {
1024
+ self.lines.push(String::new());
1025
+ }
1026
+ self.row = 0;
1027
+ self.col = 0;
1028
+ self.scroll = 0;
1029
+ }
1030
+
1031
+ pub fn text(&self) -> String {
1032
+ self.lines.join("\n")
1033
+ }
1034
+
1035
+ fn visible_text(&mut self, height: usize) -> String {
1036
+ if self.row < self.scroll {
1037
+ self.scroll = self.row;
1038
+ } else if height > 0 && self.row >= self.scroll + height {
1039
+ self.scroll = self.row + 1 - height;
1040
+ }
1041
+ let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
1042
+ self.lines
1043
+ .iter()
1044
+ .enumerate()
1045
+ .skip(self.scroll)
1046
+ .take(height.max(1))
1047
+ .map(|(index, line)| {
1048
+ let cursor = if index == self.row { ">" } else { " " };
1049
+ format!("{cursor}{:>width$} {line}", index + 1, width = line_width)
1050
+ })
1051
+ .collect::<Vec<_>>()
1052
+ .join("\n")
1053
+ }
1054
+
1055
+ fn cursor_position(&self, area: Rect) -> Option<Position> {
1056
+ if self.row < self.scroll {
1057
+ return None;
1058
+ }
1059
+ let visible_row = self.row - self.scroll;
1060
+ let inner_height = area.height.saturating_sub(2) as usize;
1061
+ if visible_row >= inner_height {
1062
+ return None;
1063
+ }
1064
+ let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
1065
+ let prefix_width = 1 + line_width + 1;
1066
+ let line = self.lines.get(self.row)?;
1067
+ let text_before_cursor = prefix(line, self.col);
1068
+ let x = area
1069
+ .x
1070
+ .saturating_add(1)
1071
+ .saturating_add((prefix_width + display_width(&text_before_cursor)) as u16)
1072
+ .min(area.right().saturating_sub(2));
1073
+ let y = area.y.saturating_add(1).saturating_add(visible_row as u16);
1074
+ Some(Position::new(x, y))
1075
+ }
1076
+
1077
+ pub fn insert_char(&mut self, char: char) {
1078
+ self.ensure_cursor();
1079
+ let byte = byte_index(&self.lines[self.row], self.col);
1080
+ self.lines[self.row].insert(byte, char);
1081
+ self.col += 1;
1082
+ self.normalize_current_line();
1083
+ }
1084
+
1085
+ pub fn insert_newline(&mut self) {
1086
+ self.ensure_cursor();
1087
+ let byte = byte_index(&self.lines[self.row], self.col);
1088
+ let rest = self.lines[self.row].split_off(byte);
1089
+ self.lines.insert(self.row + 1, rest);
1090
+ self.row += 1;
1091
+ self.col = 0;
1092
+ }
1093
+
1094
+ pub fn backspace(&mut self) {
1095
+ self.ensure_cursor();
1096
+ if self.col > 0 {
1097
+ let start = byte_index(&self.lines[self.row], self.col - 1);
1098
+ let end = byte_index(&self.lines[self.row], self.col);
1099
+ self.lines[self.row].replace_range(start..end, "");
1100
+ self.col -= 1;
1101
+ self.normalize_current_line();
1102
+ } else if self.row > 0 {
1103
+ let current = self.lines.remove(self.row);
1104
+ self.row -= 1;
1105
+ self.col = char_len(&self.lines[self.row]);
1106
+ self.lines[self.row].push_str(&current);
1107
+ }
1108
+ }
1109
+
1110
+ fn delete(&mut self) {
1111
+ self.ensure_cursor();
1112
+ if self.col < char_len(&self.lines[self.row]) {
1113
+ let start = byte_index(&self.lines[self.row], self.col);
1114
+ let end = byte_index(&self.lines[self.row], self.col + 1);
1115
+ self.lines[self.row].replace_range(start..end, "");
1116
+ self.normalize_current_line();
1117
+ } else if self.row + 1 < self.lines.len() {
1118
+ let next = self.lines.remove(self.row + 1);
1119
+ self.lines[self.row].push_str(&next);
1120
+ }
1121
+ }
1122
+
1123
+ fn move_left(&mut self) {
1124
+ if self.col > 0 {
1125
+ self.col -= 1;
1126
+ } else if self.row > 0 {
1127
+ self.row -= 1;
1128
+ self.col = char_len(&self.lines[self.row]);
1129
+ }
1130
+ }
1131
+
1132
+ fn move_right(&mut self) {
1133
+ if self.col < char_len(&self.lines[self.row]) {
1134
+ self.col += 1;
1135
+ } else if self.row + 1 < self.lines.len() {
1136
+ self.row += 1;
1137
+ self.col = 0;
1138
+ }
1139
+ }
1140
+
1141
+ fn move_up(&mut self) {
1142
+ if self.row > 0 {
1143
+ self.row -= 1;
1144
+ self.col = self.col.min(char_len(&self.lines[self.row]));
1145
+ }
1146
+ }
1147
+
1148
+ fn move_down(&mut self) {
1149
+ if self.row + 1 < self.lines.len() {
1150
+ self.row += 1;
1151
+ self.col = self.col.min(char_len(&self.lines[self.row]));
1152
+ }
1153
+ }
1154
+
1155
+ fn ensure_cursor(&mut self) {
1156
+ if self.lines.is_empty() {
1157
+ self.lines.push(String::new());
1158
+ }
1159
+ self.row = self.row.min(self.lines.len() - 1);
1160
+ self.col = self.col.min(char_len(&self.lines[self.row]));
1161
+ }
1162
+
1163
+ fn normalize_current_line(&mut self) {
1164
+ let normalized = compose_hangul_jamo(&self.lines[self.row]);
1165
+ if normalized == self.lines[self.row] {
1166
+ self.col = self.col.min(char_len(&self.lines[self.row]));
1167
+ return;
1168
+ }
1169
+ let old_prefix = prefix(&self.lines[self.row], self.col);
1170
+ self.lines[self.row] = normalized;
1171
+ self.col = char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.lines[self.row]));
1172
+ }
1173
+ }