practicode 0.1.7 → 0.1.8

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
@@ -1,15 +1,15 @@
1
1
  use crate::{
2
2
  ai::{
3
3
  ModelCatalog, append_problem_note, available_models, provider_status, read_problem_notes,
4
- run_ai_next, run_ai_prompt,
4
+ run_ai_generate, run_ai_next, run_ai_prompt,
5
5
  },
6
6
  core::{
7
7
  AI_PROVIDERS, AppState, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem,
8
- THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge,
9
- load_bank, load_state, localized, next_problem, normalize_ai_provider,
8
+ STATE_PATH, THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for,
9
+ give_up, judge, load_bank, load_state, localized, next_problem, normalize_ai_provider,
10
10
  normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language,
11
- parse_topic_list, previous_problem, problem_by_id, record_pass, save_state, template_for,
12
- ui_text,
11
+ parse_language_list, parse_topic_list, parse_ui_language_list, previous_problem,
12
+ problem_by_id, record_pass, save_state, template_for, ui_text,
13
13
  },
14
14
  text::{
15
15
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -18,9 +18,10 @@ use crate::{
18
18
  };
19
19
  use anyhow::Result;
20
20
  use crossterm::event::{
21
- self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
22
- MouseEventKind,
21
+ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
22
+ KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
23
23
  };
24
+ use crossterm::execute;
24
25
  use ratatui::{
25
26
  DefaultTerminal, Frame,
26
27
  layout::{Constraint, Direction, Layout, Position, Rect},
@@ -31,6 +32,7 @@ use ratatui::{
31
32
  use std::{
32
33
  collections::HashMap,
33
34
  fs,
35
+ io::stdout,
34
36
  path::PathBuf,
35
37
  sync::mpsc::{self, Receiver},
36
38
  thread,
@@ -38,7 +40,11 @@ use std::{
38
40
  };
39
41
 
40
42
  mod commands;
43
+ mod editor;
44
+ mod problem_view;
45
+ mod settings_panel;
41
46
  use self::commands::COMMAND_HINTS;
47
+ pub use self::editor::TextEditor;
42
48
 
43
49
  const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
44
50
 
@@ -73,10 +79,18 @@ pub struct PracticodeApp {
73
79
  show_output: bool,
74
80
  focus: Focus,
75
81
  list_cursor: Option<usize>,
82
+ settings_cursor: Option<usize>,
76
83
  busy_label: String,
77
84
  busy_body: String,
85
+ busy_started: Option<Instant>,
78
86
  busy_frame: usize,
87
+ busy_hits: usize,
88
+ busy_misses: usize,
79
89
  task_rx: Option<Receiver<TaskResult>>,
90
+ generate_rx: Option<Receiver<String>>,
91
+ generate_bank_len: usize,
92
+ generate_started: Option<Instant>,
93
+ generate_notice: Option<String>,
80
94
  update_rx: Option<Receiver<UpdateCheck>>,
81
95
  model_rx: Option<Receiver<ModelCatalog>>,
82
96
  available_models: Vec<String>,
@@ -88,6 +102,7 @@ pub struct PracticodeApp {
88
102
  code_area: Rect,
89
103
  output_area: Rect,
90
104
  command_area: Rect,
105
+ mouse_capture: bool,
91
106
  should_quit: bool,
92
107
  }
93
108
 
@@ -102,6 +117,7 @@ enum TaskResult {
102
117
 
103
118
  impl PracticodeApp {
104
119
  pub fn new(root: PathBuf) -> Result<Self> {
120
+ let first_run = !root.join(STATE_PATH).exists();
105
121
  let bank = load_bank(&root)?;
106
122
  let state = load_state(&root, &bank)?;
107
123
  let problem = problem_by_id(&bank, &state.current_problem)
@@ -122,10 +138,18 @@ impl PracticodeApp {
122
138
  show_output: false,
123
139
  focus: Focus::Code,
124
140
  list_cursor: None,
141
+ settings_cursor: None,
125
142
  busy_label: String::new(),
126
143
  busy_body: String::new(),
144
+ busy_started: None,
127
145
  busy_frame: 0,
146
+ busy_hits: 0,
147
+ busy_misses: 0,
128
148
  task_rx: None,
149
+ generate_rx: None,
150
+ generate_bank_len: 0,
151
+ generate_started: None,
152
+ generate_notice: None,
129
153
  update_rx: None,
130
154
  model_rx: None,
131
155
  available_models: Vec::new(),
@@ -137,9 +161,16 @@ impl PracticodeApp {
137
161
  code_area: Rect::default(),
138
162
  output_area: Rect::default(),
139
163
  command_area: Rect::default(),
164
+ mouse_capture: false,
140
165
  should_quit: false,
141
166
  };
142
167
  app.load_code_editor()?;
168
+ if first_run {
169
+ save_state(&app.root, &app.state)?;
170
+ app.show_profile_with_intro(
171
+ "Welcome to practicode\n\nUse the setup panel below first.",
172
+ );
173
+ }
143
174
  Ok(app)
144
175
  }
145
176
 
@@ -147,8 +178,10 @@ impl PracticodeApp {
147
178
  self.start_update_check();
148
179
  self.start_model_check();
149
180
  while !self.should_quit {
181
+ self.sync_mouse_capture();
150
182
  terminal.draw(|frame| self.draw(frame))?;
151
183
  self.check_task();
184
+ self.check_background_generation();
152
185
  self.check_update();
153
186
  self.maybe_start_periodic_update_check();
154
187
  self.start_model_check();
@@ -165,6 +198,7 @@ impl PracticodeApp {
165
198
  }
166
199
  }
167
200
  self.save_code().ok();
201
+ self.disable_mouse_capture();
168
202
  Ok(())
169
203
  }
170
204
 
@@ -206,10 +240,22 @@ impl PracticodeApp {
206
240
  &self.busy_label
207
241
  }
208
242
 
243
+ pub fn busy_attempts_for_test(&self) -> usize {
244
+ self.busy_hits + self.busy_misses
245
+ }
246
+
209
247
  pub fn has_task(&self) -> bool {
210
248
  self.task_rx.is_some()
211
249
  }
212
250
 
251
+ pub fn has_background_generation_for_test(&self) -> bool {
252
+ self.generate_rx.is_some()
253
+ }
254
+
255
+ pub fn check_background_generation_for_test(&mut self) {
256
+ self.check_background_generation();
257
+ }
258
+
213
259
  pub fn should_quit_for_test(&self) -> bool {
214
260
  self.should_quit
215
261
  }
@@ -218,6 +264,10 @@ impl PracticodeApp {
218
264
  self.status_text()
219
265
  }
220
266
 
267
+ pub fn wants_mouse_capture_for_test(&self) -> bool {
268
+ self.wants_mouse_capture()
269
+ }
270
+
221
271
  pub fn output_for_test(&self) -> &str {
222
272
  &self.output
223
273
  }
@@ -263,21 +313,28 @@ impl PracticodeApp {
263
313
  self.output_area = body[1];
264
314
  self.command_area = vertical[2];
265
315
 
266
- let problem = Paragraph::new(self.problem_text())
267
- .block(Self::block(
268
- ui_text(&self.state.settings.ui_language, "problem"),
269
- self.state.settings.theme == "light",
270
- false,
271
- ))
272
- .wrap(Wrap { trim: false });
316
+ let light = self.state.settings.theme == "light";
317
+ let problem = Paragraph::new(problem_view::render(
318
+ &self.problem,
319
+ &self.state.settings.ui_language,
320
+ light,
321
+ ))
322
+ .style(Self::pane_style(light))
323
+ .block(Self::block(
324
+ ui_text(&self.state.settings.ui_language, "problem"),
325
+ light,
326
+ false,
327
+ ))
328
+ .wrap(Wrap { trim: false });
273
329
  frame.render_widget(problem, body[0]);
274
330
 
275
331
  if self.show_output {
276
332
  let text = self.output_text();
277
333
  let output = Paragraph::new(text)
334
+ .style(Self::pane_style(light))
278
335
  .block(Self::block(
279
336
  ui_text(&self.state.settings.ui_language, "output"),
280
- self.state.settings.theme == "light",
337
+ light,
281
338
  self.focus != Focus::Command,
282
339
  ))
283
340
  .wrap(Wrap { trim: false });
@@ -287,26 +344,23 @@ impl PracticodeApp {
287
344
  .editor
288
345
  .visible_text(body[1].height.saturating_sub(2) as usize);
289
346
  let title = format!("solution.{}", ext_for(&self.state.settings.language));
290
- let code = Paragraph::new(code).block(Self::block(
291
- &title,
292
- self.state.settings.theme == "light",
293
- self.focus == Focus::Code,
294
- ));
347
+ let code = Paragraph::new(code)
348
+ .style(Self::pane_style(light))
349
+ .block(Self::block(&title, light, self.focus == Focus::Code));
295
350
  frame.render_widget(code, body[1]);
296
351
  }
297
352
 
298
- let status =
299
- Paragraph::new(self.status_text()).style(if self.state.settings.theme == "light" {
300
- Style::default()
301
- .fg(Color::Blue)
302
- .bg(Color::Rgb(219, 234, 254))
303
- .add_modifier(Modifier::BOLD)
304
- } else {
305
- Style::default()
306
- .fg(Color::Rgb(200, 211, 245))
307
- .bg(Color::Rgb(21, 32, 51))
308
- .add_modifier(Modifier::BOLD)
309
- });
353
+ let status = Paragraph::new(self.status_text()).style(if light {
354
+ Style::default()
355
+ .fg(Color::Blue)
356
+ .bg(Color::Rgb(219, 234, 254))
357
+ .add_modifier(Modifier::BOLD)
358
+ } else {
359
+ Style::default()
360
+ .fg(Color::Rgb(200, 211, 245))
361
+ .bg(Color::Rgb(21, 32, 51))
362
+ .add_modifier(Modifier::BOLD)
363
+ });
310
364
  frame.render_widget(status, vertical[1]);
311
365
 
312
366
  let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
@@ -315,9 +369,10 @@ impl PracticodeApp {
315
369
  ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
316
370
  };
317
371
  let command = Paragraph::new(command_text)
372
+ .style(Self::pane_style(light))
318
373
  .block(Self::block(
319
374
  ui_text(&self.state.settings.ui_language, "command"),
320
- self.state.settings.theme == "light",
375
+ light,
321
376
  self.focus == Focus::Command,
322
377
  ))
323
378
  .wrap(Wrap { trim: false });
@@ -326,6 +381,32 @@ impl PracticodeApp {
326
381
  self.set_terminal_cursor(frame, body[1], vertical[2]);
327
382
  }
328
383
 
384
+ fn wants_mouse_capture(&self) -> bool {
385
+ !self.show_output
386
+ }
387
+
388
+ fn sync_mouse_capture(&mut self) {
389
+ let want = self.wants_mouse_capture();
390
+ if want == self.mouse_capture {
391
+ return;
392
+ }
393
+ let result = if want {
394
+ execute!(stdout(), EnableMouseCapture)
395
+ } else {
396
+ execute!(stdout(), DisableMouseCapture)
397
+ };
398
+ if result.is_ok() {
399
+ self.mouse_capture = want;
400
+ }
401
+ }
402
+
403
+ fn disable_mouse_capture(&mut self) {
404
+ if self.mouse_capture {
405
+ let _ = execute!(stdout(), DisableMouseCapture);
406
+ self.mouse_capture = false;
407
+ }
408
+ }
409
+
329
410
  fn output_text(&self) -> Text<'static> {
330
411
  let light = self.state.settings.theme == "light";
331
412
  let title_style = if light {
@@ -361,10 +442,40 @@ impl PracticodeApp {
361
442
  .bg(Color::Rgb(31, 41, 55))
362
443
  };
363
444
  if !self.busy_label.is_empty() {
364
- return Text::from(Line::from(Span::styled(
365
- format!("{}{}", self.busy_body, self.busy_dots()),
445
+ let elapsed = self
446
+ .busy_started
447
+ .map(|started| started.elapsed().as_secs())
448
+ .unwrap_or_default();
449
+ let mut lines = vec![Line::from(Span::styled(
450
+ format!("{}{} {}s", self.busy_body, self.busy_dots(), elapsed),
366
451
  title_style,
367
- )));
452
+ ))];
453
+ if self.busy_label == "next" {
454
+ lines.extend([
455
+ Line::default(),
456
+ Line::from(Span::styled(self.busy_game_track(), code_style)),
457
+ Line::from(Span::styled(
458
+ ui_text(&self.state.settings.ui_language, "busy_warmup").to_string(),
459
+ body_style,
460
+ )),
461
+ Line::from(Span::styled(
462
+ format!(
463
+ "{}: {} {}: {}",
464
+ ui_text(&self.state.settings.ui_language, "hits"),
465
+ self.busy_hits,
466
+ ui_text(&self.state.settings.ui_language, "misses"),
467
+ self.busy_misses
468
+ ),
469
+ label_style,
470
+ )),
471
+ Line::from(Span::styled(
472
+ ui_text(&self.state.settings.ui_language, "busy_commands_paused")
473
+ .to_string(),
474
+ body_style,
475
+ )),
476
+ ]);
477
+ }
478
+ return Text::from(lines);
368
479
  }
369
480
  let output = if self.output_is_markdown {
370
481
  render_markdown_plain(&self.output)
@@ -399,141 +510,6 @@ impl PracticodeApp {
399
510
  Text::from(lines)
400
511
  }
401
512
 
402
- fn problem_text(&self) -> Text<'static> {
403
- let lang = normalize_ui_language(&self.state.settings.ui_language);
404
- let light = self.state.settings.theme == "light";
405
- let title_style = if light {
406
- Style::default()
407
- .fg(Color::Blue)
408
- .add_modifier(Modifier::BOLD)
409
- } else {
410
- Style::default()
411
- .fg(Color::Yellow)
412
- .add_modifier(Modifier::BOLD)
413
- };
414
- let section_style = if light {
415
- Style::default()
416
- .fg(Color::Magenta)
417
- .add_modifier(Modifier::BOLD)
418
- } else {
419
- Style::default()
420
- .fg(Color::Cyan)
421
- .add_modifier(Modifier::BOLD)
422
- };
423
- let body_style = if light {
424
- Style::default().fg(Color::Black)
425
- } else {
426
- Style::default().fg(Color::Rgb(229, 231, 235))
427
- };
428
- let meta_style = if light {
429
- Style::default().fg(Color::Rgb(75, 85, 99))
430
- } else {
431
- Style::default().fg(Color::Rgb(156, 163, 175))
432
- };
433
- let code_style = if light {
434
- Style::default()
435
- .fg(Color::Black)
436
- .bg(Color::Rgb(229, 231, 235))
437
- } else {
438
- Style::default()
439
- .fg(Color::Rgb(243, 244, 246))
440
- .bg(Color::Rgb(31, 41, 55))
441
- };
442
- let number = self
443
- .problem
444
- .id
445
- .split_once('-')
446
- .map(|(number, _)| number)
447
- .unwrap_or(&self.problem.id);
448
- let mut lines = vec![
449
- Line::from(Span::styled(
450
- format!("{number}. {}", localized(&self.problem.title, &lang)),
451
- title_style,
452
- )),
453
- Line::from(Span::styled(
454
- format!(
455
- "{}: {} {}: {}",
456
- ui_text(&lang, "difficulty"),
457
- self.problem.difficulty,
458
- ui_text(&lang, "topics"),
459
- self.problem.topics.join(", ")
460
- ),
461
- meta_style,
462
- )),
463
- ];
464
- lines.push(Line::default());
465
- for line in localized(&self.problem.statement, &lang).trim_end().lines() {
466
- lines.push(Line::from(Span::styled(line.to_string(), body_style)));
467
- }
468
- Self::push_problem_section(
469
- &mut lines,
470
- ui_text(&lang, "input"),
471
- &localized(&self.problem.input, &lang),
472
- section_style,
473
- body_style,
474
- );
475
- Self::push_problem_section(
476
- &mut lines,
477
- ui_text(&lang, "output"),
478
- &localized(&self.problem.output, &lang),
479
- section_style,
480
- body_style,
481
- );
482
- lines.push(Line::default());
483
- lines.push(Line::from(Span::styled(
484
- ui_text(&lang, "examples").to_string(),
485
- section_style,
486
- )));
487
- for (index, case) in self.problem.examples.iter().enumerate() {
488
- lines.push(Line::from(Span::styled(
489
- format!(" {} {}", ui_text(&lang, "example"), index + 1),
490
- meta_style.add_modifier(Modifier::BOLD),
491
- )));
492
- lines.push(Line::from(Span::styled(
493
- format!(" {}", ui_text(&lang, "input")),
494
- meta_style,
495
- )));
496
- Self::push_code_lines(&mut lines, &case.input, code_style);
497
- lines.push(Line::from(Span::styled(
498
- format!(" {}", ui_text(&lang, "output")),
499
- meta_style,
500
- )));
501
- Self::push_code_lines(&mut lines, &case.output, code_style);
502
- }
503
- Text::from(lines)
504
- }
505
-
506
- fn push_problem_section(
507
- lines: &mut Vec<Line<'static>>,
508
- title: &str,
509
- body: &str,
510
- section_style: Style,
511
- body_style: Style,
512
- ) {
513
- lines.push(Line::default());
514
- lines.push(Line::from(Span::styled(title.to_string(), section_style)));
515
- for line in body.trim_end().lines() {
516
- lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
517
- }
518
- }
519
-
520
- fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
521
- let body = body.trim_end();
522
- if body.is_empty() {
523
- lines.push(Line::from(vec![
524
- Span::raw(" "),
525
- Span::styled("<empty>".to_string(), code_style),
526
- ]));
527
- return;
528
- }
529
- for line in body.lines() {
530
- lines.push(Line::from(vec![
531
- Span::raw(" "),
532
- Span::styled(line.to_string(), code_style),
533
- ]));
534
- }
535
- }
536
-
537
513
  fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
538
514
  let suggestions = self.command_suggestions();
539
515
  if suggestions.is_empty() || command_area.y < 3 {
@@ -565,12 +541,15 @@ impl PracticodeApp {
565
541
  .collect::<Vec<_>>();
566
542
  lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
567
543
  frame.render_widget(Clear, area);
544
+ let light = self.state.settings.theme == "light";
568
545
  frame.render_widget(
569
- Paragraph::new(lines.join("\n")).block(Self::block(
570
- ui_text(&self.state.settings.ui_language, "commands"),
571
- self.state.settings.theme == "light",
572
- true,
573
- )),
546
+ Paragraph::new(lines.join("\n"))
547
+ .style(Self::pane_style(light))
548
+ .block(Self::block(
549
+ ui_text(&self.state.settings.ui_language, "commands"),
550
+ light,
551
+ true,
552
+ )),
574
553
  area,
575
554
  );
576
555
  }
@@ -591,12 +570,38 @@ impl PracticodeApp {
591
570
  } else {
592
571
  Style::default().fg(Color::Cyan)
593
572
  };
573
+ let border = border.bg(Self::pane_bg(light));
594
574
  Block::default()
595
575
  .borders(Borders::ALL)
596
576
  .title(Self::pane_title(title, active))
577
+ .style(Self::pane_style(light))
597
578
  .border_style(border)
598
579
  }
599
580
 
581
+ fn pane_style(light: bool) -> Style {
582
+ if light {
583
+ Style::default()
584
+ .fg(Color::Rgb(17, 24, 39))
585
+ .bg(Self::pane_bg(light))
586
+ } else {
587
+ Style::default()
588
+ .fg(Color::Rgb(229, 231, 235))
589
+ .bg(Self::pane_bg(light))
590
+ }
591
+ }
592
+
593
+ fn pane_bg(light: bool) -> Color {
594
+ if light {
595
+ Color::Rgb(248, 250, 252)
596
+ } else {
597
+ Color::Rgb(17, 24, 39)
598
+ }
599
+ }
600
+
601
+ pub fn pane_style_for_test(light: bool) -> Style {
602
+ Self::pane_style(light)
603
+ }
604
+
600
605
  fn pane_title(title: &str, active: bool) -> String {
601
606
  if active {
602
607
  format!("> {title}")
@@ -610,6 +615,9 @@ impl PracticodeApp {
610
615
  self.should_quit = true;
611
616
  return Ok(());
612
617
  }
618
+ if self.handle_busy_key(key) {
619
+ return Ok(());
620
+ }
613
621
  match self.focus {
614
622
  Focus::Command => self.handle_command_key(key),
615
623
  Focus::Code => self.handle_code_key(key),
@@ -618,13 +626,19 @@ impl PracticodeApp {
618
626
  }
619
627
 
620
628
  fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<()> {
629
+ if self.task_rx.is_some() {
630
+ self.focus = Focus::Output;
631
+ return Ok(());
632
+ }
621
633
  if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
622
634
  return Ok(());
623
635
  }
624
636
  let position = Position::new(mouse.column, mouse.row);
625
637
  if self.command_area.contains(position) {
626
638
  self.focus_command();
627
- } else if self.code_area.contains(position) || self.output_area.contains(position) {
639
+ } else if self.show_output && self.output_area.contains(position) {
640
+ self.focus = Focus::Output;
641
+ } else if self.code_area.contains(position) {
628
642
  self.action_edit()?;
629
643
  }
630
644
  Ok(())
@@ -708,6 +722,20 @@ impl PracticodeApp {
708
722
  }
709
723
 
710
724
  fn handle_global_key(&mut self, key: KeyEvent) -> Result<()> {
725
+ if self.settings_cursor.is_some() {
726
+ match key.code {
727
+ KeyCode::Up | KeyCode::Char('k') => self.move_settings_cursor(-1),
728
+ KeyCode::Down | KeyCode::Char('j') => self.move_settings_cursor(1),
729
+ KeyCode::Char(' ') | KeyCode::Enter => self.change_selected_setting()?,
730
+ KeyCode::Esc => {
731
+ self.settings_cursor = None;
732
+ self.show_output = false;
733
+ self.focus = Focus::Code;
734
+ }
735
+ _ => self.handle_global_shortcut(key)?,
736
+ }
737
+ return Ok(());
738
+ }
711
739
  if let Some(cursor) = self.list_cursor {
712
740
  match key.code {
713
741
  KeyCode::Up | KeyCode::Char('k') => self.move_list_cursor(-1),
@@ -768,6 +796,21 @@ impl PracticodeApp {
768
796
  }
769
797
 
770
798
  fn handle_command(&mut self, value: &str) -> Result<()> {
799
+ if self.task_rx.is_some() {
800
+ let command = value
801
+ .trim()
802
+ .strip_prefix('/')
803
+ .unwrap_or(value.trim())
804
+ .split_whitespace()
805
+ .next()
806
+ .unwrap_or("");
807
+ if matches!(command, "exit" | "quit" | "q") {
808
+ self.should_quit = true;
809
+ } else {
810
+ self.focus = Focus::Output;
811
+ }
812
+ return Ok(());
813
+ }
771
814
  if value.is_empty() || matches!(value, "help" | "h" | "?") {
772
815
  self.list_cursor = None;
773
816
  self.write_output(&self.help_text());
@@ -806,6 +849,14 @@ impl PracticodeApp {
806
849
  "topics" | "topic" => self.set_topics(arg, false)?,
807
850
  "avoid" | "skip" if arg.is_empty() => self.show_profile(),
808
851
  "avoid" | "skip" => self.set_topics(arg, true)?,
852
+ "generate-languages" | "gen-languages" | "gen-lang" if arg.is_empty() => {
853
+ self.show_profile()
854
+ }
855
+ "generate-languages" | "gen-languages" | "gen-lang" => {
856
+ self.set_generate_languages(arg, false)?
857
+ }
858
+ "generate-ui" | "gen-ui" if arg.is_empty() => self.show_profile(),
859
+ "generate-ui" | "gen-ui" => self.set_generate_languages(arg, true)?,
809
860
  "source" | "next-source" if arg.is_empty() => {
810
861
  self.write_text_output(&self.next_source_help());
811
862
  }
@@ -871,6 +922,7 @@ impl PracticodeApp {
871
922
 
872
923
  fn action_edit(&mut self) -> Result<()> {
873
924
  self.load_code_editor()?;
925
+ self.settings_cursor = None;
874
926
  self.show_output = false;
875
927
  self.focus = Focus::Code;
876
928
  Ok(())
@@ -898,25 +950,53 @@ impl PracticodeApp {
898
950
  }
899
951
 
900
952
  fn action_next(&mut self, request: &str) -> Result<()> {
953
+ self.check_background_generation();
901
954
  let request = request.trim();
902
955
  let old_problem = self.state.current_problem.clone();
903
956
  if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
957
+ self.generate_notice = None;
904
958
  self.problem = problem;
905
959
  self.load_code_editor()?;
960
+ self.settings_cursor = None;
906
961
  self.show_output = false;
907
962
  self.focus = Focus::Code;
908
963
  return Ok(());
909
964
  }
965
+ if self.generate_rx.is_some() {
966
+ self.write_text_output(
967
+ "A background generation is already running. Keep solving; /next will pick up the new problem when it finishes.",
968
+ );
969
+ return Ok(());
970
+ }
910
971
  self.start_next_problem(old_problem, true, request.to_string());
911
972
  Ok(())
912
973
  }
913
974
 
914
975
  fn action_generate(&mut self, request: &str) {
915
- self.start_next_problem(
916
- self.state.current_problem.clone(),
917
- false,
918
- request.trim().to_string(),
919
- );
976
+ self.check_background_generation();
977
+ if self.task_rx.is_some() || self.generate_rx.is_some() {
978
+ let message = "Generation is already running; skipped duplicate /generate.";
979
+ self.generate_notice = Some(message.to_string());
980
+ self.write_text_output(message);
981
+ return;
982
+ }
983
+ self.start_background_generation(request.trim().to_string());
984
+ }
985
+
986
+ fn start_background_generation(&mut self, request: String) {
987
+ let root = self.root.clone();
988
+ let state = self.state.clone();
989
+ let (tx, rx) = mpsc::channel();
990
+ thread::spawn(move || {
991
+ let _ = tx.send(run_ai_generate(&root, &state, &request));
992
+ });
993
+ self.generate_bank_len = self.bank.len();
994
+ self.generate_started = Some(Instant::now());
995
+ self.generate_notice = Some("Generating in background.".to_string());
996
+ self.generate_rx = Some(rx);
997
+ self.settings_cursor = None;
998
+ self.show_output = false;
999
+ self.focus = Focus::Code;
920
1000
  }
921
1001
 
922
1002
  fn start_next_problem(
@@ -973,6 +1053,7 @@ impl PracticodeApp {
973
1053
  }
974
1054
  }
975
1055
  self.load_code_editor()?;
1056
+ self.settings_cursor = None;
976
1057
  self.show_output = false;
977
1058
  self.focus = Focus::Code;
978
1059
  Ok(())
@@ -985,6 +1066,7 @@ impl PracticodeApp {
985
1066
  self.write_text_output("Already at the first known problem.");
986
1067
  } else {
987
1068
  self.load_code_editor()?;
1069
+ self.settings_cursor = None;
988
1070
  self.show_output = false;
989
1071
  self.focus = Focus::Code;
990
1072
  }
@@ -1029,6 +1111,7 @@ impl PracticodeApp {
1029
1111
  self.state.settings.language = language.to_string();
1030
1112
  save_state(&self.root, &self.state)?;
1031
1113
  self.load_code_editor()?;
1114
+ self.settings_cursor = None;
1032
1115
  self.show_output = false;
1033
1116
  self.focus = Focus::Code;
1034
1117
  Ok(())
@@ -1076,38 +1159,74 @@ impl PracticodeApp {
1076
1159
  Ok(())
1077
1160
  }
1078
1161
 
1162
+ fn set_generate_languages(&mut self, value: &str, ui: bool) -> Result<()> {
1163
+ if ui {
1164
+ self.state.settings.generate_ui_languages = parse_ui_language_list(value);
1165
+ } else {
1166
+ self.state.settings.generate_languages = parse_language_list(value);
1167
+ }
1168
+ save_state(&self.root, &self.state)?;
1169
+ self.show_profile();
1170
+ Ok(())
1171
+ }
1172
+
1079
1173
  fn reset_profile(&mut self) -> Result<()> {
1080
1174
  self.state.settings.difficulty = "auto".to_string();
1081
1175
  self.state.settings.topics.clear();
1082
1176
  self.state.settings.avoid_topics.clear();
1177
+ self.state.settings.generate_languages.clear();
1178
+ self.state.settings.generate_ui_languages.clear();
1083
1179
  save_state(&self.root, &self.state)?;
1084
1180
  self.show_profile();
1085
1181
  Ok(())
1086
1182
  }
1087
1183
 
1088
1184
  fn show_profile(&mut self) {
1089
- self.write_text_output(&self.profile_text());
1185
+ self.show_profile_with_intro("");
1186
+ }
1187
+
1188
+ fn show_profile_with_intro(&mut self, intro: &str) {
1189
+ self.showing_model_status = false;
1190
+ if self.settings_cursor.is_none() {
1191
+ self.settings_cursor = Some(0);
1192
+ }
1193
+ let profile = self.profile_text();
1194
+ self.output = if intro.trim().is_empty() {
1195
+ profile
1196
+ } else {
1197
+ format!("{}\n\n{profile}", intro.trim_end())
1198
+ };
1199
+ self.output_is_markdown = false;
1200
+ self.show_output = true;
1201
+ self.focus = Focus::Output;
1090
1202
  }
1091
1203
 
1092
1204
  fn profile_text(&self) -> String {
1093
- let settings = &self.state.settings;
1094
- let topics = list_or_none(&settings.topics);
1095
- let avoid = list_or_none(&settings.avoid_topics);
1096
- format!(
1097
- "Practice profile\n\nUI language: {}\nCode language: {}\nTheme: {}\nDifficulty: {}\nPreferred topics: {}\nAvoid topics: {}\nAI provider: {}\nAI model: {}\n\nCommands\n/profile\n/difficulty auto|easy|medium|hard\n/topics arrays, strings\n/avoid dp, graph\n/language python|ts|java|rust\n/ui en|ko|ja|zh|es\n/theme dark|light",
1098
- settings.ui_language,
1099
- settings.language,
1100
- settings.theme,
1101
- settings.difficulty,
1102
- topics,
1103
- avoid,
1104
- settings.ai_provider,
1105
- if settings.ai_model == "auto" {
1106
- "auto (provider default)"
1107
- } else {
1108
- settings.ai_model.as_str()
1109
- }
1110
- )
1205
+ settings_panel::render(&self.state, self.settings_cursor)
1206
+ }
1207
+
1208
+ fn settings_row_count(&self) -> usize {
1209
+ settings_panel::row_count()
1210
+ }
1211
+
1212
+ fn move_settings_cursor(&mut self, delta: isize) {
1213
+ let len = self.settings_row_count() as isize;
1214
+ let cursor = self.settings_cursor.unwrap_or(0) as isize;
1215
+ self.settings_cursor = Some(((cursor + delta).rem_euclid(len)) as usize);
1216
+ self.show_profile();
1217
+ }
1218
+
1219
+ fn change_selected_setting(&mut self) -> Result<()> {
1220
+ let Some(row) = self.settings_cursor else {
1221
+ return Ok(());
1222
+ };
1223
+ let change = settings_panel::apply_selected(&mut self.state, row);
1224
+ if change.reload_editor {
1225
+ self.load_code_editor()?;
1226
+ }
1227
+ save_state(&self.root, &self.state)?;
1228
+ self.show_profile();
1229
+ Ok(())
1111
1230
  }
1112
1231
 
1113
1232
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
@@ -1153,6 +1272,35 @@ impl PracticodeApp {
1153
1272
  }
1154
1273
  }
1155
1274
 
1275
+ fn check_background_generation(&mut self) {
1276
+ let output = self.generate_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1277
+ let Some(output) = output else {
1278
+ return;
1279
+ };
1280
+ self.generate_rx = None;
1281
+ self.generate_started = None;
1282
+ let old_len = self.generate_bank_len;
1283
+ match load_bank(&self.root) {
1284
+ Ok(bank) => {
1285
+ let added = bank.len().saturating_sub(old_len);
1286
+ self.bank = bank;
1287
+ let _ = save_state(&self.root, &self.state);
1288
+ self.generate_notice = Some(if added > 0 {
1289
+ format!("Generated {added} problem in background. Use /next.")
1290
+ } else if output.contains("failed") {
1291
+ "Background generation failed. Use /generate to retry.".to_string()
1292
+ } else {
1293
+ "Background generation finished. Use /problems to review.".to_string()
1294
+ });
1295
+ }
1296
+ Err(error) => {
1297
+ self.generate_notice = Some(format!(
1298
+ "Background generation finished, but bank reload failed: {error}"
1299
+ ));
1300
+ }
1301
+ }
1302
+ }
1303
+
1156
1304
  fn check_update(&mut self) {
1157
1305
  let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1158
1306
  if let Some(result) = result {
@@ -1252,9 +1400,13 @@ impl PracticodeApp {
1252
1400
  }
1253
1401
 
1254
1402
  fn start_busy(&mut self, label: &str, body: &str) {
1403
+ self.settings_cursor = None;
1255
1404
  self.busy_label = label.to_string();
1256
1405
  self.busy_body = body.to_string();
1406
+ self.busy_started = Some(Instant::now());
1257
1407
  self.busy_frame = 0;
1408
+ self.busy_hits = 0;
1409
+ self.busy_misses = 0;
1258
1410
  self.show_output = true;
1259
1411
  self.focus = Focus::Output;
1260
1412
  }
@@ -1262,10 +1414,32 @@ impl PracticodeApp {
1262
1414
  fn stop_busy(&mut self) {
1263
1415
  self.busy_label.clear();
1264
1416
  self.busy_body.clear();
1417
+ self.busy_started = None;
1265
1418
  self.busy_frame = 0;
1266
1419
  }
1267
1420
 
1421
+ fn handle_busy_key(&mut self, key: KeyEvent) -> bool {
1422
+ if self.task_rx.is_none() {
1423
+ return false;
1424
+ }
1425
+ if key.code == KeyCode::Char('q') && key.modifiers.is_empty() {
1426
+ self.should_quit = true;
1427
+ } else if self.busy_label == "next"
1428
+ && key.code == KeyCode::Char(' ')
1429
+ && key.modifiers.is_empty()
1430
+ {
1431
+ if self.busy_game_on_target() {
1432
+ self.busy_hits += 1;
1433
+ } else {
1434
+ self.busy_misses += 1;
1435
+ }
1436
+ }
1437
+ self.focus = Focus::Output;
1438
+ true
1439
+ }
1440
+
1268
1441
  fn write_output(&mut self, output: &str) {
1442
+ self.settings_cursor = None;
1269
1443
  self.showing_model_status = false;
1270
1444
  self.output = output.to_string();
1271
1445
  self.output_is_markdown = true;
@@ -1274,6 +1448,7 @@ impl PracticodeApp {
1274
1448
  }
1275
1449
 
1276
1450
  fn write_text_output(&mut self, output: &str) {
1451
+ self.settings_cursor = None;
1277
1452
  self.showing_model_status = false;
1278
1453
  self.output = output.trim_end().to_string();
1279
1454
  self.output_is_markdown = false;
@@ -1631,16 +1806,18 @@ impl PracticodeApp {
1631
1806
  } else {
1632
1807
  format!("{}{}", self.busy_body, self.busy_dots())
1633
1808
  };
1634
- let tail = self
1635
- .update_notice
1636
- .as_ref()
1637
- .map(|version| {
1638
- format!(
1639
- "{}:{version} /update",
1640
- ui_text(&self.state.settings.ui_language, "update")
1641
- )
1642
- })
1643
- .unwrap_or_else(|| self.mode_hint().to_string());
1809
+ let tail = if let Some(version) = self.update_notice.as_ref() {
1810
+ format!(
1811
+ "{}:{version} /update",
1812
+ ui_text(&self.state.settings.ui_language, "update")
1813
+ )
1814
+ } else if self.task_rx.is_some() {
1815
+ self.mode_hint().to_string()
1816
+ } else if let Some(status) = self.background_generation_status() {
1817
+ status
1818
+ } else {
1819
+ self.mode_hint().to_string()
1820
+ };
1644
1821
  format!(
1645
1822
  " PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
1646
1823
  self.problem.id,
@@ -1654,18 +1831,52 @@ impl PracticodeApp {
1654
1831
  }
1655
1832
 
1656
1833
  fn next_source_help(&self) -> String {
1657
- "Next behavior: /next opens unsolved local problems first and asks AI only when none remain. Use /generate <request> to create a problem immediately.".to_string()
1834
+ "Next behavior: /next opens unsolved local problems first and asks AI only when none remain. Use /generate <request> to create a problem in the background.".to_string()
1835
+ }
1836
+
1837
+ fn background_generation_status(&self) -> Option<String> {
1838
+ if self.generate_rx.is_some() {
1839
+ let elapsed = self
1840
+ .generate_started
1841
+ .map(|started| started.elapsed().as_secs())
1842
+ .unwrap_or_default();
1843
+ Some(format!("bg generate {elapsed}s"))
1844
+ } else {
1845
+ self.generate_notice.clone()
1846
+ }
1658
1847
  }
1659
1848
 
1660
1849
  fn busy_dots(&self) -> String {
1661
1850
  ".".repeat((self.busy_frame / 8) % 4)
1662
1851
  }
1663
1852
 
1853
+ fn busy_game_track(&self) -> String {
1854
+ let width = 9;
1855
+ let target = width / 2;
1856
+ let position = (self.busy_frame / 2) % width;
1857
+ let mut cells = vec!['-'; width];
1858
+ cells[target] = '|';
1859
+ cells[position] = if position == target { 'X' } else { '*' };
1860
+ format!("[{}]", cells.into_iter().collect::<String>())
1861
+ }
1862
+
1863
+ fn busy_game_on_target(&self) -> bool {
1864
+ (self.busy_frame / 2) % 9 == 4
1865
+ }
1866
+
1664
1867
  fn mode_hint(&self) -> &'static str {
1665
1868
  let lang = &self.state.settings.ui_language;
1869
+ if self.task_rx.is_some() {
1870
+ return if self.busy_label == "next" {
1871
+ ui_text(lang, "hint_busy_next")
1872
+ } else {
1873
+ ui_text(lang, "hint_busy")
1874
+ };
1875
+ }
1666
1876
  match (self.focus, self.list_cursor.is_some(), self.show_output) {
1667
1877
  (Focus::Command, _, _) => ui_text(lang, "hint_command"),
1668
1878
  (_, true, _) => ui_text(lang, "hint_list"),
1879
+ (_, _, true) if self.settings_cursor.is_some() => ui_text(lang, "hint_settings"),
1669
1880
  (_, _, true) => ui_text(lang, "hint_output"),
1670
1881
  (Focus::Code, _, _) => ui_text(lang, "hint_code"),
1671
1882
  _ => ui_text(lang, "hint_idle"),
@@ -1691,189 +1902,3 @@ impl PracticodeApp {
1691
1902
  )
1692
1903
  }
1693
1904
  }
1694
-
1695
- fn list_or_none(values: &[String]) -> String {
1696
- if values.is_empty() {
1697
- "(none)".to_string()
1698
- } else {
1699
- values.join(", ")
1700
- }
1701
- }
1702
-
1703
- #[derive(Clone, Debug)]
1704
- pub struct TextEditor {
1705
- lines: Vec<String>,
1706
- row: usize,
1707
- col: usize,
1708
- scroll: usize,
1709
- }
1710
-
1711
- impl Default for TextEditor {
1712
- fn default() -> Self {
1713
- Self {
1714
- lines: vec![String::new()],
1715
- row: 0,
1716
- col: 0,
1717
- scroll: 0,
1718
- }
1719
- }
1720
- }
1721
-
1722
- impl TextEditor {
1723
- pub fn set_text(&mut self, text: &str) {
1724
- self.lines = text.split('\n').map(str::to_string).collect();
1725
- if text.ends_with('\n') {
1726
- self.lines.pop();
1727
- self.lines.push(String::new());
1728
- }
1729
- if self.lines.is_empty() {
1730
- self.lines.push(String::new());
1731
- }
1732
- self.row = 0;
1733
- self.col = 0;
1734
- self.scroll = 0;
1735
- }
1736
-
1737
- pub fn text(&self) -> String {
1738
- self.lines.join("\n")
1739
- }
1740
-
1741
- fn visible_text(&mut self, height: usize) -> String {
1742
- if self.row < self.scroll {
1743
- self.scroll = self.row;
1744
- } else if height > 0 && self.row >= self.scroll + height {
1745
- self.scroll = self.row + 1 - height;
1746
- }
1747
- let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
1748
- self.lines
1749
- .iter()
1750
- .enumerate()
1751
- .skip(self.scroll)
1752
- .take(height.max(1))
1753
- .map(|(index, line)| {
1754
- let cursor = if index == self.row { ">" } else { " " };
1755
- format!("{cursor}{:>width$} {line}", index + 1, width = line_width)
1756
- })
1757
- .collect::<Vec<_>>()
1758
- .join("\n")
1759
- }
1760
-
1761
- fn cursor_position(&self, area: Rect) -> Option<Position> {
1762
- if self.row < self.scroll {
1763
- return None;
1764
- }
1765
- let visible_row = self.row - self.scroll;
1766
- let inner_height = area.height.saturating_sub(2) as usize;
1767
- if visible_row >= inner_height {
1768
- return None;
1769
- }
1770
- let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
1771
- let prefix_width = 1 + line_width + 1;
1772
- let line = self.lines.get(self.row)?;
1773
- let text_before_cursor = prefix(line, self.col);
1774
- let x = area
1775
- .x
1776
- .saturating_add(1)
1777
- .saturating_add((prefix_width + display_width(&text_before_cursor)) as u16)
1778
- .min(area.right().saturating_sub(2));
1779
- let y = area.y.saturating_add(1).saturating_add(visible_row as u16);
1780
- Some(Position::new(x, y))
1781
- }
1782
-
1783
- pub fn insert_char(&mut self, char: char) {
1784
- self.ensure_cursor();
1785
- let byte = byte_index(&self.lines[self.row], self.col);
1786
- self.lines[self.row].insert(byte, char);
1787
- self.col += 1;
1788
- self.normalize_current_line();
1789
- }
1790
-
1791
- pub fn insert_newline(&mut self) {
1792
- self.ensure_cursor();
1793
- let byte = byte_index(&self.lines[self.row], self.col);
1794
- let rest = self.lines[self.row].split_off(byte);
1795
- self.lines.insert(self.row + 1, rest);
1796
- self.row += 1;
1797
- self.col = 0;
1798
- }
1799
-
1800
- pub fn backspace(&mut self) {
1801
- self.ensure_cursor();
1802
- if self.col > 0 {
1803
- let start = byte_index(&self.lines[self.row], self.col - 1);
1804
- let end = byte_index(&self.lines[self.row], self.col);
1805
- self.lines[self.row].replace_range(start..end, "");
1806
- self.col -= 1;
1807
- self.normalize_current_line();
1808
- } else if self.row > 0 {
1809
- let current = self.lines.remove(self.row);
1810
- self.row -= 1;
1811
- self.col = char_len(&self.lines[self.row]);
1812
- self.lines[self.row].push_str(&current);
1813
- }
1814
- }
1815
-
1816
- fn delete(&mut self) {
1817
- self.ensure_cursor();
1818
- if self.col < char_len(&self.lines[self.row]) {
1819
- let start = byte_index(&self.lines[self.row], self.col);
1820
- let end = byte_index(&self.lines[self.row], self.col + 1);
1821
- self.lines[self.row].replace_range(start..end, "");
1822
- self.normalize_current_line();
1823
- } else if self.row + 1 < self.lines.len() {
1824
- let next = self.lines.remove(self.row + 1);
1825
- self.lines[self.row].push_str(&next);
1826
- }
1827
- }
1828
-
1829
- fn move_left(&mut self) {
1830
- if self.col > 0 {
1831
- self.col -= 1;
1832
- } else if self.row > 0 {
1833
- self.row -= 1;
1834
- self.col = char_len(&self.lines[self.row]);
1835
- }
1836
- }
1837
-
1838
- fn move_right(&mut self) {
1839
- if self.col < char_len(&self.lines[self.row]) {
1840
- self.col += 1;
1841
- } else if self.row + 1 < self.lines.len() {
1842
- self.row += 1;
1843
- self.col = 0;
1844
- }
1845
- }
1846
-
1847
- fn move_up(&mut self) {
1848
- if self.row > 0 {
1849
- self.row -= 1;
1850
- self.col = self.col.min(char_len(&self.lines[self.row]));
1851
- }
1852
- }
1853
-
1854
- fn move_down(&mut self) {
1855
- if self.row + 1 < self.lines.len() {
1856
- self.row += 1;
1857
- self.col = self.col.min(char_len(&self.lines[self.row]));
1858
- }
1859
- }
1860
-
1861
- fn ensure_cursor(&mut self) {
1862
- if self.lines.is_empty() {
1863
- self.lines.push(String::new());
1864
- }
1865
- self.row = self.row.min(self.lines.len() - 1);
1866
- self.col = self.col.min(char_len(&self.lines[self.row]));
1867
- }
1868
-
1869
- fn normalize_current_line(&mut self) {
1870
- let normalized = compose_hangul_jamo(&self.lines[self.row]);
1871
- if normalized == self.lines[self.row] {
1872
- self.col = self.col.min(char_len(&self.lines[self.row]));
1873
- return;
1874
- }
1875
- let old_prefix = prefix(&self.lines[self.row], self.col);
1876
- self.lines[self.row] = normalized;
1877
- self.col = char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.lines[self.row]));
1878
- }
1879
- }