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/Cargo.lock +241 -152
- package/Cargo.toml +2 -2
- package/README.md +67 -14
- package/assets/i18n/en.json +13 -3
- package/assets/i18n/es.json +13 -3
- package/assets/i18n/ja.json +13 -3
- package/assets/i18n/ko.json +13 -3
- package/assets/i18n/zh.json +13 -3
- package/docs/ARCHITECTURE.md +10 -5
- package/package.json +1 -1
- package/src/ai.rs +103 -4
- package/src/core.rs +63 -4
- package/src/lib.rs +46 -7
- package/src/tui/commands.rs +21 -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 +453 -416
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
|
|
|
@@ -96,12 +111,13 @@ enum TaskResult {
|
|
|
96
111
|
Next {
|
|
97
112
|
output: String,
|
|
98
113
|
old_problem: String,
|
|
99
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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)
|
|
287
|
-
|
|
288
|
-
self.
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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"))
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1034
|
+
fallback_to_local: bool,
|
|
941
1035
|
) -> Result<()> {
|
|
942
|
-
|
|
943
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1263
|
+
fallback_to_local,
|
|
1131
1264
|
} => {
|
|
1132
|
-
if let Err(error) =
|
|
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
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
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(¤t);
|
|
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
|
-
}
|