practicode 0.1.6 → 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
 
@@ -96,12 +111,13 @@ enum TaskResult {
96
111
  Next {
97
112
  output: String,
98
113
  old_problem: String,
99
- force: bool,
114
+ fallback_to_local: bool,
100
115
  },
101
116
  }
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,14 +240,34 @@ 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
+
259
+ pub fn should_quit_for_test(&self) -> bool {
260
+ self.should_quit
261
+ }
262
+
213
263
  pub fn status_text_for_test(&self) -> String {
214
264
  self.status_text()
215
265
  }
216
266
 
267
+ pub fn wants_mouse_capture_for_test(&self) -> bool {
268
+ self.wants_mouse_capture()
269
+ }
270
+
217
271
  pub fn output_for_test(&self) -> &str {
218
272
  &self.output
219
273
  }
@@ -259,21 +313,28 @@ impl PracticodeApp {
259
313
  self.output_area = body[1];
260
314
  self.command_area = vertical[2];
261
315
 
262
- let problem = Paragraph::new(self.problem_text())
263
- .block(Self::block(
264
- ui_text(&self.state.settings.ui_language, "problem"),
265
- self.state.settings.theme == "light",
266
- false,
267
- ))
268
- .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 });
269
329
  frame.render_widget(problem, body[0]);
270
330
 
271
331
  if self.show_output {
272
332
  let text = self.output_text();
273
333
  let output = Paragraph::new(text)
334
+ .style(Self::pane_style(light))
274
335
  .block(Self::block(
275
336
  ui_text(&self.state.settings.ui_language, "output"),
276
- self.state.settings.theme == "light",
337
+ light,
277
338
  self.focus != Focus::Command,
278
339
  ))
279
340
  .wrap(Wrap { trim: false });
@@ -283,26 +344,23 @@ impl PracticodeApp {
283
344
  .editor
284
345
  .visible_text(body[1].height.saturating_sub(2) as usize);
285
346
  let title = format!("solution.{}", ext_for(&self.state.settings.language));
286
- let code = Paragraph::new(code).block(Self::block(
287
- &title,
288
- self.state.settings.theme == "light",
289
- self.focus == Focus::Code,
290
- ));
347
+ let code = Paragraph::new(code)
348
+ .style(Self::pane_style(light))
349
+ .block(Self::block(&title, light, self.focus == Focus::Code));
291
350
  frame.render_widget(code, body[1]);
292
351
  }
293
352
 
294
- let status =
295
- Paragraph::new(self.status_text()).style(if self.state.settings.theme == "light" {
296
- Style::default()
297
- .fg(Color::Blue)
298
- .bg(Color::Rgb(219, 234, 254))
299
- .add_modifier(Modifier::BOLD)
300
- } else {
301
- Style::default()
302
- .fg(Color::Rgb(200, 211, 245))
303
- .bg(Color::Rgb(21, 32, 51))
304
- .add_modifier(Modifier::BOLD)
305
- });
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
+ });
306
364
  frame.render_widget(status, vertical[1]);
307
365
 
308
366
  let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
@@ -311,9 +369,10 @@ impl PracticodeApp {
311
369
  ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
312
370
  };
313
371
  let command = Paragraph::new(command_text)
372
+ .style(Self::pane_style(light))
314
373
  .block(Self::block(
315
374
  ui_text(&self.state.settings.ui_language, "command"),
316
- self.state.settings.theme == "light",
375
+ light,
317
376
  self.focus == Focus::Command,
318
377
  ))
319
378
  .wrap(Wrap { trim: false });
@@ -322,6 +381,32 @@ impl PracticodeApp {
322
381
  self.set_terminal_cursor(frame, body[1], vertical[2]);
323
382
  }
324
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
+
325
410
  fn output_text(&self) -> Text<'static> {
326
411
  let light = self.state.settings.theme == "light";
327
412
  let title_style = if light {
@@ -357,10 +442,40 @@ impl PracticodeApp {
357
442
  .bg(Color::Rgb(31, 41, 55))
358
443
  };
359
444
  if !self.busy_label.is_empty() {
360
- return Text::from(Line::from(Span::styled(
361
- 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),
362
451
  title_style,
363
- )));
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);
364
479
  }
365
480
  let output = if self.output_is_markdown {
366
481
  render_markdown_plain(&self.output)
@@ -395,141 +510,6 @@ impl PracticodeApp {
395
510
  Text::from(lines)
396
511
  }
397
512
 
398
- fn problem_text(&self) -> Text<'static> {
399
- let lang = normalize_ui_language(&self.state.settings.ui_language);
400
- let light = self.state.settings.theme == "light";
401
- let title_style = if light {
402
- Style::default()
403
- .fg(Color::Blue)
404
- .add_modifier(Modifier::BOLD)
405
- } else {
406
- Style::default()
407
- .fg(Color::Yellow)
408
- .add_modifier(Modifier::BOLD)
409
- };
410
- let section_style = if light {
411
- Style::default()
412
- .fg(Color::Magenta)
413
- .add_modifier(Modifier::BOLD)
414
- } else {
415
- Style::default()
416
- .fg(Color::Cyan)
417
- .add_modifier(Modifier::BOLD)
418
- };
419
- let body_style = if light {
420
- Style::default().fg(Color::Black)
421
- } else {
422
- Style::default().fg(Color::Rgb(229, 231, 235))
423
- };
424
- let meta_style = if light {
425
- Style::default().fg(Color::Rgb(75, 85, 99))
426
- } else {
427
- Style::default().fg(Color::Rgb(156, 163, 175))
428
- };
429
- let code_style = if light {
430
- Style::default()
431
- .fg(Color::Black)
432
- .bg(Color::Rgb(229, 231, 235))
433
- } else {
434
- Style::default()
435
- .fg(Color::Rgb(243, 244, 246))
436
- .bg(Color::Rgb(31, 41, 55))
437
- };
438
- let number = self
439
- .problem
440
- .id
441
- .split_once('-')
442
- .map(|(number, _)| number)
443
- .unwrap_or(&self.problem.id);
444
- let mut lines = vec![
445
- Line::from(Span::styled(
446
- format!("{number}. {}", localized(&self.problem.title, &lang)),
447
- title_style,
448
- )),
449
- Line::from(Span::styled(
450
- format!(
451
- "{}: {} {}: {}",
452
- ui_text(&lang, "difficulty"),
453
- self.problem.difficulty,
454
- ui_text(&lang, "topics"),
455
- self.problem.topics.join(", ")
456
- ),
457
- meta_style,
458
- )),
459
- ];
460
- lines.push(Line::default());
461
- for line in localized(&self.problem.statement, &lang).trim_end().lines() {
462
- lines.push(Line::from(Span::styled(line.to_string(), body_style)));
463
- }
464
- Self::push_problem_section(
465
- &mut lines,
466
- ui_text(&lang, "input"),
467
- &localized(&self.problem.input, &lang),
468
- section_style,
469
- body_style,
470
- );
471
- Self::push_problem_section(
472
- &mut lines,
473
- ui_text(&lang, "output"),
474
- &localized(&self.problem.output, &lang),
475
- section_style,
476
- body_style,
477
- );
478
- lines.push(Line::default());
479
- lines.push(Line::from(Span::styled(
480
- ui_text(&lang, "examples").to_string(),
481
- section_style,
482
- )));
483
- for (index, case) in self.problem.examples.iter().enumerate() {
484
- lines.push(Line::from(Span::styled(
485
- format!(" {} {}", ui_text(&lang, "example"), index + 1),
486
- meta_style.add_modifier(Modifier::BOLD),
487
- )));
488
- lines.push(Line::from(Span::styled(
489
- format!(" {}", ui_text(&lang, "input")),
490
- meta_style,
491
- )));
492
- Self::push_code_lines(&mut lines, &case.input, code_style);
493
- lines.push(Line::from(Span::styled(
494
- format!(" {}", ui_text(&lang, "output")),
495
- meta_style,
496
- )));
497
- Self::push_code_lines(&mut lines, &case.output, code_style);
498
- }
499
- Text::from(lines)
500
- }
501
-
502
- fn push_problem_section(
503
- lines: &mut Vec<Line<'static>>,
504
- title: &str,
505
- body: &str,
506
- section_style: Style,
507
- body_style: Style,
508
- ) {
509
- lines.push(Line::default());
510
- lines.push(Line::from(Span::styled(title.to_string(), section_style)));
511
- for line in body.trim_end().lines() {
512
- lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
513
- }
514
- }
515
-
516
- fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
517
- let body = body.trim_end();
518
- if body.is_empty() {
519
- lines.push(Line::from(vec![
520
- Span::raw(" "),
521
- Span::styled("<empty>".to_string(), code_style),
522
- ]));
523
- return;
524
- }
525
- for line in body.lines() {
526
- lines.push(Line::from(vec![
527
- Span::raw(" "),
528
- Span::styled(line.to_string(), code_style),
529
- ]));
530
- }
531
- }
532
-
533
513
  fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
534
514
  let suggestions = self.command_suggestions();
535
515
  if suggestions.is_empty() || command_area.y < 3 {
@@ -561,12 +541,15 @@ impl PracticodeApp {
561
541
  .collect::<Vec<_>>();
562
542
  lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
563
543
  frame.render_widget(Clear, area);
544
+ let light = self.state.settings.theme == "light";
564
545
  frame.render_widget(
565
- Paragraph::new(lines.join("\n")).block(Self::block(
566
- ui_text(&self.state.settings.ui_language, "commands"),
567
- self.state.settings.theme == "light",
568
- true,
569
- )),
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
+ )),
570
553
  area,
571
554
  );
572
555
  }
@@ -587,12 +570,38 @@ impl PracticodeApp {
587
570
  } else {
588
571
  Style::default().fg(Color::Cyan)
589
572
  };
573
+ let border = border.bg(Self::pane_bg(light));
590
574
  Block::default()
591
575
  .borders(Borders::ALL)
592
576
  .title(Self::pane_title(title, active))
577
+ .style(Self::pane_style(light))
593
578
  .border_style(border)
594
579
  }
595
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
+
596
605
  fn pane_title(title: &str, active: bool) -> String {
597
606
  if active {
598
607
  format!("> {title}")
@@ -602,6 +611,13 @@ impl PracticodeApp {
602
611
  }
603
612
 
604
613
  fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
614
+ if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
615
+ self.should_quit = true;
616
+ return Ok(());
617
+ }
618
+ if self.handle_busy_key(key) {
619
+ return Ok(());
620
+ }
605
621
  match self.focus {
606
622
  Focus::Command => self.handle_command_key(key),
607
623
  Focus::Code => self.handle_code_key(key),
@@ -610,13 +626,19 @@ impl PracticodeApp {
610
626
  }
611
627
 
612
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
+ }
613
633
  if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
614
634
  return Ok(());
615
635
  }
616
636
  let position = Position::new(mouse.column, mouse.row);
617
637
  if self.command_area.contains(position) {
618
638
  self.focus_command();
619
- } 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) {
620
642
  self.action_edit()?;
621
643
  }
622
644
  Ok(())
@@ -700,6 +722,20 @@ impl PracticodeApp {
700
722
  }
701
723
 
702
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
+ }
703
739
  if let Some(cursor) = self.list_cursor {
704
740
  match key.code {
705
741
  KeyCode::Up | KeyCode::Char('k') => self.move_list_cursor(-1),
@@ -760,6 +796,21 @@ impl PracticodeApp {
760
796
  }
761
797
 
762
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
+ }
763
814
  if value.is_empty() || matches!(value, "help" | "h" | "?") {
764
815
  self.list_cursor = None;
765
816
  self.write_output(&self.help_text());
@@ -779,6 +830,7 @@ impl PracticodeApp {
779
830
  "run" | "r" => self.action_run()?,
780
831
  "code" | "edit" | "e" => self.action_edit()?,
781
832
  "next" | "n" => self.action_next(arg)?,
833
+ "generate" | "gen" | "new" => self.action_generate(arg),
782
834
  "back" | "prev" | "previous" | "p" => self.action_previous()?,
783
835
  "answer" | "giveup" | "give" | "g" => self.action_give_up()?,
784
836
  "problems" | "list" => self.start_problem_list(),
@@ -797,6 +849,14 @@ impl PracticodeApp {
797
849
  "topics" | "topic" => self.set_topics(arg, false)?,
798
850
  "avoid" | "skip" if arg.is_empty() => self.show_profile(),
799
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)?,
800
860
  "source" | "next-source" if arg.is_empty() => {
801
861
  self.write_text_output(&self.next_source_help());
802
862
  }
@@ -862,6 +922,7 @@ impl PracticodeApp {
862
922
 
863
923
  fn action_edit(&mut self) -> Result<()> {
864
924
  self.load_code_editor()?;
925
+ self.settings_cursor = None;
865
926
  self.show_output = false;
866
927
  self.focus = Focus::Code;
867
928
  Ok(())
@@ -889,28 +950,61 @@ impl PracticodeApp {
889
950
  }
890
951
 
891
952
  fn action_next(&mut self, request: &str) -> Result<()> {
953
+ self.check_background_generation();
892
954
  let request = request.trim();
893
955
  let old_problem = self.state.current_problem.clone();
894
- if !request.is_empty() {
895
- self.start_next_problem(old_problem, true, request.to_string());
896
- return Ok(());
897
- }
898
- if self.state.settings.next_source == "ai" {
899
- self.start_next_problem(old_problem, false, String::new());
900
- return Ok(());
901
- }
902
956
  if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
957
+ self.generate_notice = None;
903
958
  self.problem = problem;
904
959
  self.load_code_editor()?;
960
+ self.settings_cursor = None;
905
961
  self.show_output = false;
906
962
  self.focus = Focus::Code;
907
963
  return Ok(());
908
964
  }
909
- self.start_next_problem(old_problem, true, String::new());
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
+ }
971
+ self.start_next_problem(old_problem, true, request.to_string());
910
972
  Ok(())
911
973
  }
912
974
 
913
- fn start_next_problem(&mut self, old_problem: String, force: bool, request: String) {
975
+ fn action_generate(&mut self, request: &str) {
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;
1000
+ }
1001
+
1002
+ fn start_next_problem(
1003
+ &mut self,
1004
+ old_problem: String,
1005
+ fallback_to_local: bool,
1006
+ request: String,
1007
+ ) {
914
1008
  if self.task_rx.is_some() {
915
1009
  self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
916
1010
  return;
@@ -923,11 +1017,11 @@ impl PracticodeApp {
923
1017
  let state = self.state.clone();
924
1018
  let (tx, rx) = mpsc::channel();
925
1019
  thread::spawn(move || {
926
- let output = run_ai_next(&root, &state, force, &request);
1020
+ let output = run_ai_next(&root, &state, true, &request);
927
1021
  let _ = tx.send(TaskResult::Next {
928
1022
  output,
929
1023
  old_problem,
930
- force,
1024
+ fallback_to_local,
931
1025
  });
932
1026
  });
933
1027
  self.task_rx = Some(rx);
@@ -937,17 +1031,17 @@ impl PracticodeApp {
937
1031
  &mut self,
938
1032
  output: String,
939
1033
  old_problem: String,
940
- force: bool,
1034
+ fallback_to_local: bool,
941
1035
  ) -> Result<()> {
942
- if self.state.settings.next_source == "ai" || force {
943
- self.bank = load_bank(&self.root)?;
944
- self.state = load_state(&self.root, &self.bank)?;
945
- }
1036
+ self.bank = load_bank(&self.root)?;
1037
+ self.state = load_state(&self.root, &self.bank)?;
946
1038
  self.problem = problem_by_id(&self.bank, &self.state.current_problem)
947
1039
  .cloned()
948
1040
  .unwrap_or_else(|| self.bank[0].clone());
949
1041
  if self.state.current_problem == old_problem {
950
- if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
1042
+ if fallback_to_local
1043
+ && let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)?
1044
+ {
951
1045
  self.problem = problem;
952
1046
  } else {
953
1047
  self.write_text_output(&format!(
@@ -959,6 +1053,7 @@ impl PracticodeApp {
959
1053
  }
960
1054
  }
961
1055
  self.load_code_editor()?;
1056
+ self.settings_cursor = None;
962
1057
  self.show_output = false;
963
1058
  self.focus = Focus::Code;
964
1059
  Ok(())
@@ -971,6 +1066,7 @@ impl PracticodeApp {
971
1066
  self.write_text_output("Already at the first known problem.");
972
1067
  } else {
973
1068
  self.load_code_editor()?;
1069
+ self.settings_cursor = None;
974
1070
  self.show_output = false;
975
1071
  self.focus = Focus::Code;
976
1072
  }
@@ -1015,6 +1111,7 @@ impl PracticodeApp {
1015
1111
  self.state.settings.language = language.to_string();
1016
1112
  save_state(&self.root, &self.state)?;
1017
1113
  self.load_code_editor()?;
1114
+ self.settings_cursor = None;
1018
1115
  self.show_output = false;
1019
1116
  self.focus = Focus::Code;
1020
1117
  Ok(())
@@ -1062,38 +1159,74 @@ impl PracticodeApp {
1062
1159
  Ok(())
1063
1160
  }
1064
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
+
1065
1173
  fn reset_profile(&mut self) -> Result<()> {
1066
1174
  self.state.settings.difficulty = "auto".to_string();
1067
1175
  self.state.settings.topics.clear();
1068
1176
  self.state.settings.avoid_topics.clear();
1177
+ self.state.settings.generate_languages.clear();
1178
+ self.state.settings.generate_ui_languages.clear();
1069
1179
  save_state(&self.root, &self.state)?;
1070
1180
  self.show_profile();
1071
1181
  Ok(())
1072
1182
  }
1073
1183
 
1074
1184
  fn show_profile(&mut self) {
1075
- 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;
1076
1202
  }
1077
1203
 
1078
1204
  fn profile_text(&self) -> String {
1079
- let settings = &self.state.settings;
1080
- let topics = list_or_none(&settings.topics);
1081
- let avoid = list_or_none(&settings.avoid_topics);
1082
- format!(
1083
- "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",
1084
- settings.ui_language,
1085
- settings.language,
1086
- settings.theme,
1087
- settings.difficulty,
1088
- topics,
1089
- avoid,
1090
- settings.ai_provider,
1091
- if settings.ai_model == "auto" {
1092
- "auto (provider default)"
1093
- } else {
1094
- settings.ai_model.as_str()
1095
- }
1096
- )
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(())
1097
1230
  }
1098
1231
 
1099
1232
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
@@ -1127,9 +1260,11 @@ impl PracticodeApp {
1127
1260
  TaskResult::Next {
1128
1261
  output,
1129
1262
  old_problem,
1130
- force,
1263
+ fallback_to_local,
1131
1264
  } => {
1132
- if let Err(error) = self.finish_next_problem(output, old_problem, force) {
1265
+ if let Err(error) =
1266
+ self.finish_next_problem(output, old_problem, fallback_to_local)
1267
+ {
1133
1268
  self.write_text_output(&format!("Next failed\n{error}"));
1134
1269
  }
1135
1270
  }
@@ -1137,6 +1272,35 @@ impl PracticodeApp {
1137
1272
  }
1138
1273
  }
1139
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
+
1140
1304
  fn check_update(&mut self) {
1141
1305
  let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1142
1306
  if let Some(result) = result {
@@ -1236,9 +1400,13 @@ impl PracticodeApp {
1236
1400
  }
1237
1401
 
1238
1402
  fn start_busy(&mut self, label: &str, body: &str) {
1403
+ self.settings_cursor = None;
1239
1404
  self.busy_label = label.to_string();
1240
1405
  self.busy_body = body.to_string();
1406
+ self.busy_started = Some(Instant::now());
1241
1407
  self.busy_frame = 0;
1408
+ self.busy_hits = 0;
1409
+ self.busy_misses = 0;
1242
1410
  self.show_output = true;
1243
1411
  self.focus = Focus::Output;
1244
1412
  }
@@ -1246,10 +1414,32 @@ impl PracticodeApp {
1246
1414
  fn stop_busy(&mut self) {
1247
1415
  self.busy_label.clear();
1248
1416
  self.busy_body.clear();
1417
+ self.busy_started = None;
1249
1418
  self.busy_frame = 0;
1250
1419
  }
1251
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
+
1252
1441
  fn write_output(&mut self, output: &str) {
1442
+ self.settings_cursor = None;
1253
1443
  self.showing_model_status = false;
1254
1444
  self.output = output.to_string();
1255
1445
  self.output_is_markdown = true;
@@ -1258,6 +1448,7 @@ impl PracticodeApp {
1258
1448
  }
1259
1449
 
1260
1450
  fn write_text_output(&mut self, output: &str) {
1451
+ self.settings_cursor = None;
1261
1452
  self.showing_model_status = false;
1262
1453
  self.output = output.trim_end().to_string();
1263
1454
  self.output_is_markdown = false;
@@ -1615,16 +1806,18 @@ impl PracticodeApp {
1615
1806
  } else {
1616
1807
  format!("{}{}", self.busy_body, self.busy_dots())
1617
1808
  };
1618
- let tail = self
1619
- .update_notice
1620
- .as_ref()
1621
- .map(|version| {
1622
- format!(
1623
- "{}:{version} /update",
1624
- ui_text(&self.state.settings.ui_language, "update")
1625
- )
1626
- })
1627
- .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
+ };
1628
1821
  format!(
1629
1822
  " PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
1630
1823
  self.problem.id,
@@ -1638,10 +1831,18 @@ impl PracticodeApp {
1638
1831
  }
1639
1832
 
1640
1833
  fn next_source_help(&self) -> String {
1641
- if self.state.settings.next_source == "ai" {
1642
- "Next behavior: plain /next asks AI every time. /next <request> also sends that request.".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"))
1643
1844
  } else {
1644
- "Next behavior: /next uses local problems first and asks AI when it needs a new one. Use /next <request> to ask AI directly.".to_string()
1845
+ self.generate_notice.clone()
1645
1846
  }
1646
1847
  }
1647
1848
 
@@ -1649,11 +1850,33 @@ impl PracticodeApp {
1649
1850
  ".".repeat((self.busy_frame / 8) % 4)
1650
1851
  }
1651
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
+
1652
1867
  fn mode_hint(&self) -> &'static str {
1653
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
+ }
1654
1876
  match (self.focus, self.list_cursor.is_some(), self.show_output) {
1655
1877
  (Focus::Command, _, _) => ui_text(lang, "hint_command"),
1656
1878
  (_, true, _) => ui_text(lang, "hint_list"),
1879
+ (_, _, true) if self.settings_cursor.is_some() => ui_text(lang, "hint_settings"),
1657
1880
  (_, _, true) => ui_text(lang, "hint_output"),
1658
1881
  (Focus::Code, _, _) => ui_text(lang, "hint_code"),
1659
1882
  _ => ui_text(lang, "hint_idle"),
@@ -1679,189 +1902,3 @@ impl PracticodeApp {
1679
1902
  )
1680
1903
  }
1681
1904
  }
1682
-
1683
- fn list_or_none(values: &[String]) -> String {
1684
- if values.is_empty() {
1685
- "(none)".to_string()
1686
- } else {
1687
- values.join(", ")
1688
- }
1689
- }
1690
-
1691
- #[derive(Clone, Debug)]
1692
- pub struct TextEditor {
1693
- lines: Vec<String>,
1694
- row: usize,
1695
- col: usize,
1696
- scroll: usize,
1697
- }
1698
-
1699
- impl Default for TextEditor {
1700
- fn default() -> Self {
1701
- Self {
1702
- lines: vec![String::new()],
1703
- row: 0,
1704
- col: 0,
1705
- scroll: 0,
1706
- }
1707
- }
1708
- }
1709
-
1710
- impl TextEditor {
1711
- pub fn set_text(&mut self, text: &str) {
1712
- self.lines = text.split('\n').map(str::to_string).collect();
1713
- if text.ends_with('\n') {
1714
- self.lines.pop();
1715
- self.lines.push(String::new());
1716
- }
1717
- if self.lines.is_empty() {
1718
- self.lines.push(String::new());
1719
- }
1720
- self.row = 0;
1721
- self.col = 0;
1722
- self.scroll = 0;
1723
- }
1724
-
1725
- pub fn text(&self) -> String {
1726
- self.lines.join("\n")
1727
- }
1728
-
1729
- fn visible_text(&mut self, height: usize) -> String {
1730
- if self.row < self.scroll {
1731
- self.scroll = self.row;
1732
- } else if height > 0 && self.row >= self.scroll + height {
1733
- self.scroll = self.row + 1 - height;
1734
- }
1735
- let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
1736
- self.lines
1737
- .iter()
1738
- .enumerate()
1739
- .skip(self.scroll)
1740
- .take(height.max(1))
1741
- .map(|(index, line)| {
1742
- let cursor = if index == self.row { ">" } else { " " };
1743
- format!("{cursor}{:>width$} {line}", index + 1, width = line_width)
1744
- })
1745
- .collect::<Vec<_>>()
1746
- .join("\n")
1747
- }
1748
-
1749
- fn cursor_position(&self, area: Rect) -> Option<Position> {
1750
- if self.row < self.scroll {
1751
- return None;
1752
- }
1753
- let visible_row = self.row - self.scroll;
1754
- let inner_height = area.height.saturating_sub(2) as usize;
1755
- if visible_row >= inner_height {
1756
- return None;
1757
- }
1758
- let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
1759
- let prefix_width = 1 + line_width + 1;
1760
- let line = self.lines.get(self.row)?;
1761
- let text_before_cursor = prefix(line, self.col);
1762
- let x = area
1763
- .x
1764
- .saturating_add(1)
1765
- .saturating_add((prefix_width + display_width(&text_before_cursor)) as u16)
1766
- .min(area.right().saturating_sub(2));
1767
- let y = area.y.saturating_add(1).saturating_add(visible_row as u16);
1768
- Some(Position::new(x, y))
1769
- }
1770
-
1771
- pub fn insert_char(&mut self, char: char) {
1772
- self.ensure_cursor();
1773
- let byte = byte_index(&self.lines[self.row], self.col);
1774
- self.lines[self.row].insert(byte, char);
1775
- self.col += 1;
1776
- self.normalize_current_line();
1777
- }
1778
-
1779
- pub fn insert_newline(&mut self) {
1780
- self.ensure_cursor();
1781
- let byte = byte_index(&self.lines[self.row], self.col);
1782
- let rest = self.lines[self.row].split_off(byte);
1783
- self.lines.insert(self.row + 1, rest);
1784
- self.row += 1;
1785
- self.col = 0;
1786
- }
1787
-
1788
- pub fn backspace(&mut self) {
1789
- self.ensure_cursor();
1790
- if self.col > 0 {
1791
- let start = byte_index(&self.lines[self.row], self.col - 1);
1792
- let end = byte_index(&self.lines[self.row], self.col);
1793
- self.lines[self.row].replace_range(start..end, "");
1794
- self.col -= 1;
1795
- self.normalize_current_line();
1796
- } else if self.row > 0 {
1797
- let current = self.lines.remove(self.row);
1798
- self.row -= 1;
1799
- self.col = char_len(&self.lines[self.row]);
1800
- self.lines[self.row].push_str(&current);
1801
- }
1802
- }
1803
-
1804
- fn delete(&mut self) {
1805
- self.ensure_cursor();
1806
- if self.col < char_len(&self.lines[self.row]) {
1807
- let start = byte_index(&self.lines[self.row], self.col);
1808
- let end = byte_index(&self.lines[self.row], self.col + 1);
1809
- self.lines[self.row].replace_range(start..end, "");
1810
- self.normalize_current_line();
1811
- } else if self.row + 1 < self.lines.len() {
1812
- let next = self.lines.remove(self.row + 1);
1813
- self.lines[self.row].push_str(&next);
1814
- }
1815
- }
1816
-
1817
- fn move_left(&mut self) {
1818
- if self.col > 0 {
1819
- self.col -= 1;
1820
- } else if self.row > 0 {
1821
- self.row -= 1;
1822
- self.col = char_len(&self.lines[self.row]);
1823
- }
1824
- }
1825
-
1826
- fn move_right(&mut self) {
1827
- if self.col < char_len(&self.lines[self.row]) {
1828
- self.col += 1;
1829
- } else if self.row + 1 < self.lines.len() {
1830
- self.row += 1;
1831
- self.col = 0;
1832
- }
1833
- }
1834
-
1835
- fn move_up(&mut self) {
1836
- if self.row > 0 {
1837
- self.row -= 1;
1838
- self.col = self.col.min(char_len(&self.lines[self.row]));
1839
- }
1840
- }
1841
-
1842
- fn move_down(&mut self) {
1843
- if self.row + 1 < self.lines.len() {
1844
- self.row += 1;
1845
- self.col = self.col.min(char_len(&self.lines[self.row]));
1846
- }
1847
- }
1848
-
1849
- fn ensure_cursor(&mut self) {
1850
- if self.lines.is_empty() {
1851
- self.lines.push(String::new());
1852
- }
1853
- self.row = self.row.min(self.lines.len() - 1);
1854
- self.col = self.col.min(char_len(&self.lines[self.row]));
1855
- }
1856
-
1857
- fn normalize_current_line(&mut self) {
1858
- let normalized = compose_hangul_jamo(&self.lines[self.row]);
1859
- if normalized == self.lines[self.row] {
1860
- self.col = self.col.min(char_len(&self.lines[self.row]));
1861
- return;
1862
- }
1863
- let old_prefix = prefix(&self.lines[self.row], self.col);
1864
- self.lines[self.row] = normalized;
1865
- self.col = char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.lines[self.row]));
1866
- }
1867
- }