practicode 0.1.7 → 0.1.9
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +28 -8
- package/SECURITY.md +11 -0
- package/assets/i18n/en.json +13 -4
- package/assets/i18n/es.json +13 -4
- package/assets/i18n/ja.json +13 -4
- package/assets/i18n/ko.json +13 -4
- package/assets/i18n/zh.json +13 -4
- package/docs/ARCHITECTURE.md +10 -5
- package/docs/MAINTAINING.md +5 -0
- package/package.json +7 -2
- package/src/ai.rs +103 -4
- package/src/core.rs +63 -4
- package/src/lib.rs +2 -6
- package/src/tui/commands.rs +14 -0
- package/src/tui/editor.rs +180 -0
- package/src/tui/problem_view.rs +138 -0
- package/src/tui/settings_panel.rs +319 -0
- package/src/tui.rs +423 -398
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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)
|
|
291
|
-
|
|
292
|
-
self.
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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"))
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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.
|
|
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.
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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.
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
|
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(¤t);
|
|
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
|
-
}
|