practicode 0.1.0 → 0.1.2
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 +43 -101
- package/THIRD_PARTY_LICENSES.md +58 -0
- package/assets/i18n/en.json +50 -0
- package/assets/i18n/es.json +50 -0
- package/assets/i18n/ja.json +50 -0
- package/assets/i18n/ko.json +50 -0
- package/assets/i18n/zh.json +50 -0
- package/docs/CONTRIBUTING.md +81 -0
- package/package.json +4 -2
- package/scripts/release.sh +69 -0
- package/src/ai.rs +38 -6
- package/src/core.rs +116 -32
- package/src/i18n.rs +45 -0
- package/src/lib.rs +2 -0
- package/src/tui.rs +470 -91
- package/src/update.rs +45 -0
package/src/tui.rs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
use crate::{
|
|
2
|
-
ai::{append_problem_note, read_problem_notes, run_ai_next, run_ai_prompt},
|
|
2
|
+
ai::{append_problem_note, provider_status, read_problem_notes, run_ai_next, run_ai_prompt},
|
|
3
3
|
core::{
|
|
4
4
|
AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
|
|
5
5
|
UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
|
|
6
6
|
load_state, localized, next_problem, normalize_ai_provider, normalize_language,
|
|
7
|
-
normalize_next_source, previous_problem, problem_by_id, record_pass,
|
|
8
|
-
save_state, template_for,
|
|
7
|
+
normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
|
|
8
|
+
render_problem_tui, save_state, template_for, ui_text,
|
|
9
9
|
},
|
|
10
10
|
text::{
|
|
11
11
|
byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
|
|
12
12
|
},
|
|
13
|
+
update::{CURRENT_VERSION, UpdateCheck, check_latest_version},
|
|
13
14
|
};
|
|
14
15
|
use anyhow::Result;
|
|
15
16
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
@@ -17,7 +18,7 @@ use ratatui::{
|
|
|
17
18
|
DefaultTerminal, Frame,
|
|
18
19
|
layout::{Constraint, Direction, Layout, Position, Rect},
|
|
19
20
|
style::{Color, Modifier, Style},
|
|
20
|
-
widgets::{Block, Borders, Paragraph, Wrap},
|
|
21
|
+
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
|
21
22
|
};
|
|
22
23
|
use std::{
|
|
23
24
|
collections::HashMap,
|
|
@@ -28,46 +29,227 @@ use std::{
|
|
|
28
29
|
time::Duration,
|
|
29
30
|
};
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
## Commands
|
|
40
|
-
|
|
41
|
-
- `/run` judge current submission
|
|
42
|
-
- `/edit` focus the code editor
|
|
43
|
-
- `/next [request]` next problem, optionally with a request
|
|
44
|
-
- `/prev` previous problem
|
|
45
|
-
- `/list` choose from problem list
|
|
46
|
-
- `/open 2` open by number, id, or slug
|
|
47
|
-
- `/giveup` show answer
|
|
48
|
-
- `/ai hint` ask the selected AI about current problem + code
|
|
49
|
-
- `/provider codex|claude`
|
|
50
|
-
- `/model auto|sonnet|opus|...`
|
|
51
|
-
- `/note prefer strings this week`
|
|
52
|
-
- `/notes` show next-problem notes
|
|
53
|
-
- `/lang python|ts|java|rust`
|
|
54
|
-
- `/ui ko|en`
|
|
55
|
-
- `/theme dark|light`
|
|
56
|
-
- `/source bank|ai`
|
|
57
|
-
- `/exit` quit
|
|
58
|
-
|
|
59
|
-
## Keys
|
|
60
|
-
|
|
61
|
-
- `Esc` leaves the editor or output pane
|
|
62
|
-
- `/` opens the command bar when the editor is not focused
|
|
63
|
-
- `?` opens this help when the editor is not focused
|
|
64
|
-
- `up/down` or `j/k` move in `/list`
|
|
65
|
-
|
|
66
|
-
## Debug prints
|
|
32
|
+
#[derive(Clone, Copy)]
|
|
33
|
+
struct CommandHint {
|
|
34
|
+
insert: &'static str,
|
|
35
|
+
display: &'static str,
|
|
36
|
+
desc_key: &'static str,
|
|
37
|
+
keep_open: bool,
|
|
38
|
+
help: bool,
|
|
39
|
+
}
|
|
67
40
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
41
|
+
const COMMAND_HINTS: &[CommandHint] = &[
|
|
42
|
+
CommandHint {
|
|
43
|
+
insert: "run",
|
|
44
|
+
display: "/run",
|
|
45
|
+
desc_key: "cmd_run",
|
|
46
|
+
keep_open: false,
|
|
47
|
+
help: true,
|
|
48
|
+
},
|
|
49
|
+
CommandHint {
|
|
50
|
+
insert: "edit",
|
|
51
|
+
display: "/edit",
|
|
52
|
+
desc_key: "cmd_edit",
|
|
53
|
+
keep_open: false,
|
|
54
|
+
help: true,
|
|
55
|
+
},
|
|
56
|
+
CommandHint {
|
|
57
|
+
insert: "next",
|
|
58
|
+
display: "/next",
|
|
59
|
+
desc_key: "cmd_next",
|
|
60
|
+
keep_open: false,
|
|
61
|
+
help: true,
|
|
62
|
+
},
|
|
63
|
+
CommandHint {
|
|
64
|
+
insert: "prev",
|
|
65
|
+
display: "/prev",
|
|
66
|
+
desc_key: "cmd_prev",
|
|
67
|
+
keep_open: false,
|
|
68
|
+
help: true,
|
|
69
|
+
},
|
|
70
|
+
CommandHint {
|
|
71
|
+
insert: "list",
|
|
72
|
+
display: "/list",
|
|
73
|
+
desc_key: "cmd_list",
|
|
74
|
+
keep_open: false,
|
|
75
|
+
help: true,
|
|
76
|
+
},
|
|
77
|
+
CommandHint {
|
|
78
|
+
insert: "open ",
|
|
79
|
+
display: "/open <id>",
|
|
80
|
+
desc_key: "cmd_open",
|
|
81
|
+
keep_open: true,
|
|
82
|
+
help: true,
|
|
83
|
+
},
|
|
84
|
+
CommandHint {
|
|
85
|
+
insert: "giveup",
|
|
86
|
+
display: "/giveup",
|
|
87
|
+
desc_key: "cmd_giveup",
|
|
88
|
+
keep_open: false,
|
|
89
|
+
help: true,
|
|
90
|
+
},
|
|
91
|
+
CommandHint {
|
|
92
|
+
insert: "ai ",
|
|
93
|
+
display: "/ai <prompt>",
|
|
94
|
+
desc_key: "cmd_ai",
|
|
95
|
+
keep_open: true,
|
|
96
|
+
help: true,
|
|
97
|
+
},
|
|
98
|
+
CommandHint {
|
|
99
|
+
insert: "provider codex",
|
|
100
|
+
display: "/provider codex",
|
|
101
|
+
desc_key: "cmd_provider",
|
|
102
|
+
keep_open: false,
|
|
103
|
+
help: true,
|
|
104
|
+
},
|
|
105
|
+
CommandHint {
|
|
106
|
+
insert: "provider claude",
|
|
107
|
+
display: "/provider claude",
|
|
108
|
+
desc_key: "cmd_provider",
|
|
109
|
+
keep_open: false,
|
|
110
|
+
help: false,
|
|
111
|
+
},
|
|
112
|
+
CommandHint {
|
|
113
|
+
insert: "model auto",
|
|
114
|
+
display: "/model auto",
|
|
115
|
+
desc_key: "cmd_model",
|
|
116
|
+
keep_open: false,
|
|
117
|
+
help: true,
|
|
118
|
+
},
|
|
119
|
+
CommandHint {
|
|
120
|
+
insert: "model ",
|
|
121
|
+
display: "/model <name>",
|
|
122
|
+
desc_key: "cmd_model",
|
|
123
|
+
keep_open: true,
|
|
124
|
+
help: false,
|
|
125
|
+
},
|
|
126
|
+
CommandHint {
|
|
127
|
+
insert: "note ",
|
|
128
|
+
display: "/note <text>",
|
|
129
|
+
desc_key: "cmd_note",
|
|
130
|
+
keep_open: true,
|
|
131
|
+
help: true,
|
|
132
|
+
},
|
|
133
|
+
CommandHint {
|
|
134
|
+
insert: "notes",
|
|
135
|
+
display: "/notes",
|
|
136
|
+
desc_key: "cmd_notes",
|
|
137
|
+
keep_open: false,
|
|
138
|
+
help: true,
|
|
139
|
+
},
|
|
140
|
+
CommandHint {
|
|
141
|
+
insert: "lang python",
|
|
142
|
+
display: "/lang python",
|
|
143
|
+
desc_key: "cmd_lang",
|
|
144
|
+
keep_open: false,
|
|
145
|
+
help: true,
|
|
146
|
+
},
|
|
147
|
+
CommandHint {
|
|
148
|
+
insert: "lang ts",
|
|
149
|
+
display: "/lang ts",
|
|
150
|
+
desc_key: "cmd_lang",
|
|
151
|
+
keep_open: false,
|
|
152
|
+
help: false,
|
|
153
|
+
},
|
|
154
|
+
CommandHint {
|
|
155
|
+
insert: "lang java",
|
|
156
|
+
display: "/lang java",
|
|
157
|
+
desc_key: "cmd_lang",
|
|
158
|
+
keep_open: false,
|
|
159
|
+
help: false,
|
|
160
|
+
},
|
|
161
|
+
CommandHint {
|
|
162
|
+
insert: "lang rust",
|
|
163
|
+
display: "/lang rust",
|
|
164
|
+
desc_key: "cmd_lang",
|
|
165
|
+
keep_open: false,
|
|
166
|
+
help: false,
|
|
167
|
+
},
|
|
168
|
+
CommandHint {
|
|
169
|
+
insert: "ui en",
|
|
170
|
+
display: "/ui en",
|
|
171
|
+
desc_key: "cmd_ui",
|
|
172
|
+
keep_open: false,
|
|
173
|
+
help: true,
|
|
174
|
+
},
|
|
175
|
+
CommandHint {
|
|
176
|
+
insert: "ui ko",
|
|
177
|
+
display: "/ui ko",
|
|
178
|
+
desc_key: "cmd_ui",
|
|
179
|
+
keep_open: false,
|
|
180
|
+
help: false,
|
|
181
|
+
},
|
|
182
|
+
CommandHint {
|
|
183
|
+
insert: "ui ja",
|
|
184
|
+
display: "/ui ja",
|
|
185
|
+
desc_key: "cmd_ui",
|
|
186
|
+
keep_open: false,
|
|
187
|
+
help: false,
|
|
188
|
+
},
|
|
189
|
+
CommandHint {
|
|
190
|
+
insert: "ui zh",
|
|
191
|
+
display: "/ui zh",
|
|
192
|
+
desc_key: "cmd_ui",
|
|
193
|
+
keep_open: false,
|
|
194
|
+
help: false,
|
|
195
|
+
},
|
|
196
|
+
CommandHint {
|
|
197
|
+
insert: "ui es",
|
|
198
|
+
display: "/ui es",
|
|
199
|
+
desc_key: "cmd_ui",
|
|
200
|
+
keep_open: false,
|
|
201
|
+
help: false,
|
|
202
|
+
},
|
|
203
|
+
CommandHint {
|
|
204
|
+
insert: "theme dark",
|
|
205
|
+
display: "/theme dark",
|
|
206
|
+
desc_key: "cmd_theme",
|
|
207
|
+
keep_open: false,
|
|
208
|
+
help: true,
|
|
209
|
+
},
|
|
210
|
+
CommandHint {
|
|
211
|
+
insert: "theme light",
|
|
212
|
+
display: "/theme light",
|
|
213
|
+
desc_key: "cmd_theme",
|
|
214
|
+
keep_open: false,
|
|
215
|
+
help: false,
|
|
216
|
+
},
|
|
217
|
+
CommandHint {
|
|
218
|
+
insert: "source local",
|
|
219
|
+
display: "/source local",
|
|
220
|
+
desc_key: "cmd_source",
|
|
221
|
+
keep_open: false,
|
|
222
|
+
help: false,
|
|
223
|
+
},
|
|
224
|
+
CommandHint {
|
|
225
|
+
insert: "source ai",
|
|
226
|
+
display: "/source ai",
|
|
227
|
+
desc_key: "cmd_source",
|
|
228
|
+
keep_open: false,
|
|
229
|
+
help: false,
|
|
230
|
+
},
|
|
231
|
+
CommandHint {
|
|
232
|
+
insert: "update",
|
|
233
|
+
display: "/update",
|
|
234
|
+
desc_key: "cmd_update",
|
|
235
|
+
keep_open: false,
|
|
236
|
+
help: true,
|
|
237
|
+
},
|
|
238
|
+
CommandHint {
|
|
239
|
+
insert: "help",
|
|
240
|
+
display: "/help",
|
|
241
|
+
desc_key: "cmd_help",
|
|
242
|
+
keep_open: false,
|
|
243
|
+
help: true,
|
|
244
|
+
},
|
|
245
|
+
CommandHint {
|
|
246
|
+
insert: "exit",
|
|
247
|
+
display: "/exit",
|
|
248
|
+
desc_key: "cmd_exit",
|
|
249
|
+
keep_open: false,
|
|
250
|
+
help: true,
|
|
251
|
+
},
|
|
252
|
+
];
|
|
71
253
|
|
|
72
254
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
73
255
|
enum Focus {
|
|
@@ -85,6 +267,7 @@ pub struct PracticodeApp {
|
|
|
85
267
|
editor: TextEditor,
|
|
86
268
|
command: String,
|
|
87
269
|
command_cursor: usize,
|
|
270
|
+
command_palette_cursor: usize,
|
|
88
271
|
output: String,
|
|
89
272
|
output_is_markdown: bool,
|
|
90
273
|
show_output: bool,
|
|
@@ -94,6 +277,9 @@ pub struct PracticodeApp {
|
|
|
94
277
|
busy_body: String,
|
|
95
278
|
busy_frame: usize,
|
|
96
279
|
task_rx: Option<Receiver<TaskResult>>,
|
|
280
|
+
update_rx: Option<Receiver<UpdateCheck>>,
|
|
281
|
+
update_check: Option<UpdateCheck>,
|
|
282
|
+
update_notice: Option<String>,
|
|
97
283
|
should_quit: bool,
|
|
98
284
|
}
|
|
99
285
|
|
|
@@ -121,6 +307,7 @@ impl PracticodeApp {
|
|
|
121
307
|
editor: TextEditor::default(),
|
|
122
308
|
command: String::new(),
|
|
123
309
|
command_cursor: 0,
|
|
310
|
+
command_palette_cursor: 0,
|
|
124
311
|
output: String::new(),
|
|
125
312
|
output_is_markdown: false,
|
|
126
313
|
show_output: false,
|
|
@@ -130,6 +317,9 @@ impl PracticodeApp {
|
|
|
130
317
|
busy_body: String::new(),
|
|
131
318
|
busy_frame: 0,
|
|
132
319
|
task_rx: None,
|
|
320
|
+
update_rx: None,
|
|
321
|
+
update_check: None,
|
|
322
|
+
update_notice: None,
|
|
133
323
|
should_quit: false,
|
|
134
324
|
};
|
|
135
325
|
app.load_code_editor()?;
|
|
@@ -137,9 +327,11 @@ impl PracticodeApp {
|
|
|
137
327
|
}
|
|
138
328
|
|
|
139
329
|
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
|
330
|
+
self.start_update_check();
|
|
140
331
|
while !self.should_quit {
|
|
141
332
|
terminal.draw(|frame| self.draw(frame))?;
|
|
142
333
|
self.check_task();
|
|
334
|
+
self.check_update();
|
|
143
335
|
if event::poll(Duration::from_millis(100))?
|
|
144
336
|
&& let Event::Key(key) = event::read()?
|
|
145
337
|
&& key.kind != KeyEventKind::Release
|
|
@@ -147,7 +339,7 @@ impl PracticodeApp {
|
|
|
147
339
|
self.handle_key(key)?;
|
|
148
340
|
}
|
|
149
341
|
if !self.busy_label.is_empty() {
|
|
150
|
-
self.busy_frame = (self.busy_frame + 1) %
|
|
342
|
+
self.busy_frame = (self.busy_frame + 1) % 16;
|
|
151
343
|
}
|
|
152
344
|
}
|
|
153
345
|
self.save_code().ok();
|
|
@@ -174,6 +366,10 @@ impl PracticodeApp {
|
|
|
174
366
|
self.command_cursor
|
|
175
367
|
}
|
|
176
368
|
|
|
369
|
+
pub fn handle_key_for_test(&mut self, key: KeyEvent) -> Result<()> {
|
|
370
|
+
self.handle_key(key)
|
|
371
|
+
}
|
|
372
|
+
|
|
177
373
|
pub fn busy_label(&self) -> &str {
|
|
178
374
|
&self.busy_label
|
|
179
375
|
}
|
|
@@ -182,6 +378,10 @@ impl PracticodeApp {
|
|
|
182
378
|
self.task_rx.is_some()
|
|
183
379
|
}
|
|
184
380
|
|
|
381
|
+
pub fn status_text_for_test(&self) -> String {
|
|
382
|
+
self.status_text()
|
|
383
|
+
}
|
|
384
|
+
|
|
185
385
|
fn draw(&mut self, frame: &mut Frame) {
|
|
186
386
|
let size = frame.area();
|
|
187
387
|
let vertical = Layout::default()
|
|
@@ -197,24 +397,30 @@ impl PracticodeApp {
|
|
|
197
397
|
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
|
|
198
398
|
.split(vertical[0]);
|
|
199
399
|
|
|
200
|
-
let problem = Paragraph::new(
|
|
400
|
+
let problem = Paragraph::new(render_problem_tui(
|
|
201
401
|
&self.problem,
|
|
202
402
|
&self.state.settings.ui_language,
|
|
203
|
-
))
|
|
204
|
-
.block(Self::block(
|
|
403
|
+
))
|
|
404
|
+
.block(Self::block(
|
|
405
|
+
ui_text(&self.state.settings.ui_language, "problem"),
|
|
406
|
+
self.state.settings.theme == "light",
|
|
407
|
+
))
|
|
205
408
|
.wrap(Wrap { trim: false });
|
|
206
409
|
frame.render_widget(problem, body[0]);
|
|
207
410
|
|
|
208
411
|
if self.show_output {
|
|
209
412
|
let text = if !self.busy_label.is_empty() {
|
|
210
|
-
format!("{}{}", self.busy_body,
|
|
413
|
+
format!("{}{}", self.busy_body, self.busy_dots())
|
|
211
414
|
} else if self.output_is_markdown {
|
|
212
415
|
render_markdown_plain(&self.output)
|
|
213
416
|
} else {
|
|
214
417
|
self.output.clone()
|
|
215
418
|
};
|
|
216
419
|
let output = Paragraph::new(text)
|
|
217
|
-
.block(Self::block(
|
|
420
|
+
.block(Self::block(
|
|
421
|
+
ui_text(&self.state.settings.ui_language, "output"),
|
|
422
|
+
self.state.settings.theme == "light",
|
|
423
|
+
))
|
|
218
424
|
.wrap(Wrap { trim: false });
|
|
219
425
|
frame.render_widget(output, body[1]);
|
|
220
426
|
} else {
|
|
@@ -244,15 +450,56 @@ impl PracticodeApp {
|
|
|
244
450
|
let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
|
|
245
451
|
self.command.clone()
|
|
246
452
|
} else {
|
|
247
|
-
|
|
453
|
+
ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
|
|
248
454
|
};
|
|
249
455
|
let command = Paragraph::new(command_text)
|
|
250
|
-
.block(Self::block(
|
|
456
|
+
.block(Self::block(
|
|
457
|
+
ui_text(&self.state.settings.ui_language, "command"),
|
|
458
|
+
self.state.settings.theme == "light",
|
|
459
|
+
))
|
|
251
460
|
.wrap(Wrap { trim: false });
|
|
252
461
|
frame.render_widget(command, vertical[2]);
|
|
462
|
+
self.draw_command_palette(frame, vertical[2]);
|
|
253
463
|
self.set_terminal_cursor(frame, body[1], vertical[2]);
|
|
254
464
|
}
|
|
255
465
|
|
|
466
|
+
fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
|
|
467
|
+
let suggestions = self.command_suggestions();
|
|
468
|
+
if suggestions.is_empty() || command_area.y < 3 {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
let height = ((suggestions.len() + 3) as u16).min(10).min(command_area.y);
|
|
472
|
+
let area = Rect::new(
|
|
473
|
+
command_area.x,
|
|
474
|
+
command_area.y - height,
|
|
475
|
+
command_area.width,
|
|
476
|
+
height,
|
|
477
|
+
);
|
|
478
|
+
let selected = self.command_palette_cursor.min(suggestions.len() - 1);
|
|
479
|
+
let mut lines = suggestions
|
|
480
|
+
.iter()
|
|
481
|
+
.enumerate()
|
|
482
|
+
.take(height.saturating_sub(2) as usize)
|
|
483
|
+
.map(|(index, hint)| {
|
|
484
|
+
let marker = if index == selected { ">" } else { " " };
|
|
485
|
+
format!(
|
|
486
|
+
"{marker} {:<16} {}",
|
|
487
|
+
hint.display,
|
|
488
|
+
ui_text(&self.state.settings.ui_language, hint.desc_key)
|
|
489
|
+
)
|
|
490
|
+
})
|
|
491
|
+
.collect::<Vec<_>>();
|
|
492
|
+
lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
|
|
493
|
+
frame.render_widget(Clear, area);
|
|
494
|
+
frame.render_widget(
|
|
495
|
+
Paragraph::new(lines.join("\n")).block(Self::block(
|
|
496
|
+
ui_text(&self.state.settings.ui_language, "commands"),
|
|
497
|
+
self.state.settings.theme == "light",
|
|
498
|
+
)),
|
|
499
|
+
area,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
256
503
|
fn block(title: &str, light: bool) -> Block<'_> {
|
|
257
504
|
Block::default()
|
|
258
505
|
.borders(Borders::ALL)
|
|
@@ -277,14 +524,18 @@ impl PracticodeApp {
|
|
|
277
524
|
KeyCode::Esc => {
|
|
278
525
|
self.command.clear();
|
|
279
526
|
self.command_cursor = 0;
|
|
527
|
+
self.command_palette_cursor = 0;
|
|
280
528
|
self.focus = Focus::None;
|
|
281
529
|
}
|
|
282
530
|
KeyCode::Enter => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
531
|
+
if !self.accept_command_palette()? {
|
|
532
|
+
let value = self.command.trim().to_string();
|
|
533
|
+
self.command.clear();
|
|
534
|
+
self.command_cursor = 0;
|
|
535
|
+
self.command_palette_cursor = 0;
|
|
536
|
+
self.focus = Focus::None;
|
|
537
|
+
self.submit_command(&value)?;
|
|
538
|
+
}
|
|
288
539
|
}
|
|
289
540
|
KeyCode::Backspace => self.delete_command_before_cursor(),
|
|
290
541
|
KeyCode::Delete => self.delete_command_at_cursor(),
|
|
@@ -292,11 +543,14 @@ impl PracticodeApp {
|
|
|
292
543
|
KeyCode::Right => {
|
|
293
544
|
self.command_cursor = (self.command_cursor + 1).min(char_len(&self.command));
|
|
294
545
|
}
|
|
546
|
+
KeyCode::Up => self.move_command_palette(-1),
|
|
547
|
+
KeyCode::Down => self.move_command_palette(1),
|
|
295
548
|
KeyCode::Home => self.command_cursor = 0,
|
|
296
549
|
KeyCode::End => self.command_cursor = char_len(&self.command),
|
|
297
550
|
KeyCode::Char('?') if self.command.trim().is_empty() || self.command.trim() == "/" => {
|
|
298
551
|
self.command.clear();
|
|
299
552
|
self.command_cursor = 0;
|
|
553
|
+
self.command_palette_cursor = 0;
|
|
300
554
|
self.focus = Focus::None;
|
|
301
555
|
self.handle_command("help")?;
|
|
302
556
|
}
|
|
@@ -389,6 +643,7 @@ impl PracticodeApp {
|
|
|
389
643
|
self.command.push('/');
|
|
390
644
|
self.command_cursor = 1;
|
|
391
645
|
}
|
|
646
|
+
self.command_palette_cursor = 0;
|
|
392
647
|
self.focus = Focus::Command;
|
|
393
648
|
}
|
|
394
649
|
|
|
@@ -404,7 +659,7 @@ impl PracticodeApp {
|
|
|
404
659
|
fn handle_command(&mut self, value: &str) -> Result<()> {
|
|
405
660
|
if value.is_empty() || matches!(value, "help" | "h" | "?") {
|
|
406
661
|
self.list_cursor = None;
|
|
407
|
-
self.write_output(
|
|
662
|
+
self.write_output(&self.help_text());
|
|
408
663
|
return Ok(());
|
|
409
664
|
}
|
|
410
665
|
if value.starts_with("vim") {
|
|
@@ -428,22 +683,16 @@ impl PracticodeApp {
|
|
|
428
683
|
"lang" if arg.is_empty() => self.action_cycle_language()?,
|
|
429
684
|
"lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
|
|
430
685
|
"ui" if arg.is_empty() => self.action_toggle_ui_language()?,
|
|
431
|
-
"ui"
|
|
686
|
+
"ui" => self.set_ui_language(&normalize_ui_language(arg))?,
|
|
432
687
|
"theme" if arg.is_empty() => self.action_toggle_theme()?,
|
|
433
688
|
"theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
|
|
434
689
|
"source" | "next-source" if arg.is_empty() => {
|
|
435
|
-
self.write_text_output(&format!(
|
|
436
|
-
"Next source: {}",
|
|
437
|
-
self.state.settings.next_source
|
|
438
|
-
));
|
|
690
|
+
self.write_text_output(&format!("Next source: {}", self.next_source_label()));
|
|
439
691
|
}
|
|
440
|
-
"source" | "next-source" if matches!(arg, "bank" | "ai") => {
|
|
692
|
+
"source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
|
|
441
693
|
self.state.settings.next_source = normalize_next_source(arg);
|
|
442
694
|
save_state(&self.root, &self.state)?;
|
|
443
|
-
self.write_text_output(&format!(
|
|
444
|
-
"Next source: {}",
|
|
445
|
-
self.state.settings.next_source
|
|
446
|
-
));
|
|
695
|
+
self.write_text_output(&format!("Next source: {}", self.next_source_label()));
|
|
447
696
|
}
|
|
448
697
|
"ai-next-command" if !arg.is_empty() => {
|
|
449
698
|
self.state.settings.ai_next_command = arg.to_string();
|
|
@@ -453,16 +702,18 @@ impl PracticodeApp {
|
|
|
453
702
|
}
|
|
454
703
|
"provider" | "ai-provider" if arg.is_empty() => {
|
|
455
704
|
self.write_text_output(&format!(
|
|
456
|
-
"AI provider: {}",
|
|
457
|
-
self.state.settings.ai_provider
|
|
705
|
+
"AI provider: {}\n{}",
|
|
706
|
+
self.state.settings.ai_provider,
|
|
707
|
+
provider_status(&self.state.settings.ai_provider)
|
|
458
708
|
));
|
|
459
709
|
}
|
|
460
710
|
"provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
|
|
461
711
|
self.state.settings.ai_provider = normalize_ai_provider(arg);
|
|
462
712
|
save_state(&self.root, &self.state)?;
|
|
463
713
|
self.write_text_output(&format!(
|
|
464
|
-
"AI provider: {}",
|
|
465
|
-
self.state.settings.ai_provider
|
|
714
|
+
"AI provider: {}\n{}",
|
|
715
|
+
self.state.settings.ai_provider,
|
|
716
|
+
provider_status(&self.state.settings.ai_provider)
|
|
466
717
|
));
|
|
467
718
|
}
|
|
468
719
|
"model" if arg.is_empty() => {
|
|
@@ -476,6 +727,7 @@ impl PracticodeApp {
|
|
|
476
727
|
"ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
|
|
477
728
|
"note" if !arg.is_empty() => self.append_note(arg)?,
|
|
478
729
|
"note" | "notes" => self.show_notes()?,
|
|
730
|
+
"update" => self.show_update_notice(),
|
|
479
731
|
"exit" | "quit" | "q" => self.should_quit = true,
|
|
480
732
|
_ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
|
|
481
733
|
}
|
|
@@ -534,10 +786,13 @@ impl PracticodeApp {
|
|
|
534
786
|
|
|
535
787
|
fn start_next_problem(&mut self, old_problem: String, force: bool, request: String) {
|
|
536
788
|
if self.task_rx.is_some() {
|
|
537
|
-
self.write_text_output(
|
|
789
|
+
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
|
|
538
790
|
return;
|
|
539
791
|
}
|
|
540
|
-
self.start_busy(
|
|
792
|
+
self.start_busy(
|
|
793
|
+
"next",
|
|
794
|
+
ui_text(&self.state.settings.ui_language, "generating_next"),
|
|
795
|
+
);
|
|
541
796
|
let root = self.root.clone();
|
|
542
797
|
let state = self.state.clone();
|
|
543
798
|
let (tx, rx) = mpsc::channel();
|
|
@@ -640,9 +895,9 @@ impl PracticodeApp {
|
|
|
640
895
|
}
|
|
641
896
|
|
|
642
897
|
fn set_ui_language(&mut self, language: &str) -> Result<()> {
|
|
643
|
-
self.state.settings.ui_language = language
|
|
898
|
+
self.state.settings.ui_language = normalize_ui_language(language);
|
|
644
899
|
save_state(&self.root, &self.state)?;
|
|
645
|
-
self.write_text_output(&format!("UI language: {
|
|
900
|
+
self.write_text_output(&format!("UI language: {}", self.state.settings.ui_language));
|
|
646
901
|
Ok(())
|
|
647
902
|
}
|
|
648
903
|
|
|
@@ -655,7 +910,7 @@ impl PracticodeApp {
|
|
|
655
910
|
|
|
656
911
|
fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
|
|
657
912
|
if self.task_rx.is_some() {
|
|
658
|
-
self.write_text_output(
|
|
913
|
+
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
|
|
659
914
|
return Ok(());
|
|
660
915
|
}
|
|
661
916
|
self.save_code()?;
|
|
@@ -694,6 +949,28 @@ impl PracticodeApp {
|
|
|
694
949
|
}
|
|
695
950
|
}
|
|
696
951
|
|
|
952
|
+
fn check_update(&mut self) {
|
|
953
|
+
let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
|
954
|
+
if let Some(result) = result {
|
|
955
|
+
self.update_rx = None;
|
|
956
|
+
self.update_check = Some(result.clone());
|
|
957
|
+
if let UpdateCheck::Available(version) = &result {
|
|
958
|
+
self.update_notice = Some(version.clone());
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
fn start_update_check(&mut self) {
|
|
964
|
+
if self.update_rx.is_some() {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
let (tx, rx) = mpsc::channel();
|
|
968
|
+
thread::spawn(move || {
|
|
969
|
+
let _ = tx.send(check_latest_version());
|
|
970
|
+
});
|
|
971
|
+
self.update_rx = Some(rx);
|
|
972
|
+
}
|
|
973
|
+
|
|
697
974
|
fn start_busy(&mut self, label: &str, body: &str) {
|
|
698
975
|
self.busy_label = label.to_string();
|
|
699
976
|
self.busy_body = body.to_string();
|
|
@@ -722,6 +999,24 @@ impl PracticodeApp {
|
|
|
722
999
|
self.focus = Focus::Output;
|
|
723
1000
|
}
|
|
724
1001
|
|
|
1002
|
+
fn show_update_notice(&mut self) {
|
|
1003
|
+
let lang = self.state.settings.ui_language.clone();
|
|
1004
|
+
if let Some(version) = &self.update_notice {
|
|
1005
|
+
self.write_text_output(&format!(
|
|
1006
|
+
"{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode",
|
|
1007
|
+
ui_text(&lang, "update_available")
|
|
1008
|
+
));
|
|
1009
|
+
} else if self.update_rx.is_some() {
|
|
1010
|
+
self.write_text_output("Checking for updates...");
|
|
1011
|
+
} else if matches!(self.update_check, Some(UpdateCheck::Disabled)) {
|
|
1012
|
+
self.write_text_output(ui_text(&lang, "update_check_disabled"));
|
|
1013
|
+
} else if matches!(self.update_check, Some(UpdateCheck::Failed)) {
|
|
1014
|
+
self.write_text_output(ui_text(&lang, "update_check_failed"));
|
|
1015
|
+
} else {
|
|
1016
|
+
self.write_text_output(ui_text(&lang, "update_none"));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
725
1020
|
fn append_note(&mut self, note: &str) -> Result<()> {
|
|
726
1021
|
append_problem_note(&self.root, note)?;
|
|
727
1022
|
self.write_text_output(&format!("Problem note saved to {PROBLEM_NOTES_PATH}."));
|
|
@@ -742,6 +1037,7 @@ impl PracticodeApp {
|
|
|
742
1037
|
let byte = byte_index(&self.command, self.command_cursor);
|
|
743
1038
|
self.command.insert(byte, char);
|
|
744
1039
|
self.command_cursor += 1;
|
|
1040
|
+
self.command_palette_cursor = 0;
|
|
745
1041
|
self.normalize_command_input();
|
|
746
1042
|
}
|
|
747
1043
|
|
|
@@ -753,6 +1049,7 @@ impl PracticodeApp {
|
|
|
753
1049
|
let end = byte_index(&self.command, self.command_cursor);
|
|
754
1050
|
self.command.replace_range(start..end, "");
|
|
755
1051
|
self.command_cursor -= 1;
|
|
1052
|
+
self.command_palette_cursor = 0;
|
|
756
1053
|
self.normalize_command_input();
|
|
757
1054
|
}
|
|
758
1055
|
|
|
@@ -763,9 +1060,55 @@ impl PracticodeApp {
|
|
|
763
1060
|
let start = byte_index(&self.command, self.command_cursor);
|
|
764
1061
|
let end = byte_index(&self.command, self.command_cursor + 1);
|
|
765
1062
|
self.command.replace_range(start..end, "");
|
|
1063
|
+
self.command_palette_cursor = 0;
|
|
766
1064
|
self.normalize_command_input();
|
|
767
1065
|
}
|
|
768
1066
|
|
|
1067
|
+
fn command_suggestions(&self) -> Vec<&'static CommandHint> {
|
|
1068
|
+
if self.focus != Focus::Command {
|
|
1069
|
+
return Vec::new();
|
|
1070
|
+
}
|
|
1071
|
+
let Some(query) = self.command.trim_start().strip_prefix('/') else {
|
|
1072
|
+
return Vec::new();
|
|
1073
|
+
};
|
|
1074
|
+
let query = query.to_lowercase();
|
|
1075
|
+
COMMAND_HINTS
|
|
1076
|
+
.iter()
|
|
1077
|
+
.filter(|hint| hint.insert.starts_with(query.trim_start()))
|
|
1078
|
+
.take(7)
|
|
1079
|
+
.collect()
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
fn move_command_palette(&mut self, delta: isize) {
|
|
1083
|
+
let len = self.command_suggestions().len();
|
|
1084
|
+
if len == 0 {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
let cursor = self.command_palette_cursor as isize;
|
|
1088
|
+
self.command_palette_cursor = ((cursor + delta).rem_euclid(len as isize)) as usize;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
fn accept_command_palette(&mut self) -> Result<bool> {
|
|
1092
|
+
let suggestions = self.command_suggestions();
|
|
1093
|
+
if suggestions.is_empty() {
|
|
1094
|
+
return Ok(false);
|
|
1095
|
+
}
|
|
1096
|
+
let hint = suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
|
|
1097
|
+
if hint.keep_open {
|
|
1098
|
+
self.command = format!("/{}", hint.insert);
|
|
1099
|
+
self.command_cursor = char_len(&self.command);
|
|
1100
|
+
self.command_palette_cursor = 0;
|
|
1101
|
+
return Ok(true);
|
|
1102
|
+
}
|
|
1103
|
+
let value = hint.insert;
|
|
1104
|
+
self.command.clear();
|
|
1105
|
+
self.command_cursor = 0;
|
|
1106
|
+
self.command_palette_cursor = 0;
|
|
1107
|
+
self.focus = Focus::None;
|
|
1108
|
+
self.submit_command(value)?;
|
|
1109
|
+
Ok(true)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
769
1112
|
fn normalize_command_input(&mut self) {
|
|
770
1113
|
let normalized = compose_hangul_jamo(&self.command);
|
|
771
1114
|
if normalized == self.command {
|
|
@@ -960,38 +1303,74 @@ impl PracticodeApp {
|
|
|
960
1303
|
|
|
961
1304
|
fn status_text(&self) -> String {
|
|
962
1305
|
let code_status = self.submission_status(&self.problem).0;
|
|
1306
|
+
let activity = if self.busy_label.is_empty() {
|
|
1307
|
+
"idle".to_string()
|
|
1308
|
+
} else {
|
|
1309
|
+
format!("{}{}", self.busy_body, self.busy_dots())
|
|
1310
|
+
};
|
|
1311
|
+
let tail = self
|
|
1312
|
+
.update_notice
|
|
1313
|
+
.as_ref()
|
|
1314
|
+
.map(|version| {
|
|
1315
|
+
format!(
|
|
1316
|
+
"{}:{version} /update",
|
|
1317
|
+
ui_text(&self.state.settings.ui_language, "update")
|
|
1318
|
+
)
|
|
1319
|
+
})
|
|
1320
|
+
.unwrap_or_else(|| self.mode_hint().to_string());
|
|
963
1321
|
format!(
|
|
964
|
-
" PRACTICODE | {} | {} | {} | {} | code:{} | {} |
|
|
1322
|
+
" PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
|
|
965
1323
|
self.problem.id,
|
|
966
1324
|
self.problem.difficulty,
|
|
967
|
-
self.busy_status(),
|
|
968
1325
|
self.problem_status(&self.problem),
|
|
1326
|
+
activity,
|
|
969
1327
|
code_status,
|
|
970
1328
|
self.state.settings.language,
|
|
971
|
-
|
|
972
|
-
self.state.settings.ai_provider,
|
|
973
|
-
self.state.settings.ai_model,
|
|
974
|
-
self.mode_hint(),
|
|
1329
|
+
tail,
|
|
975
1330
|
)
|
|
976
1331
|
}
|
|
977
1332
|
|
|
978
|
-
fn
|
|
979
|
-
if self.
|
|
980
|
-
"
|
|
1333
|
+
fn next_source_label(&self) -> &'static str {
|
|
1334
|
+
if self.state.settings.next_source == "ai" {
|
|
1335
|
+
"ai"
|
|
981
1336
|
} else {
|
|
982
|
-
|
|
1337
|
+
"local"
|
|
983
1338
|
}
|
|
984
1339
|
}
|
|
985
1340
|
|
|
1341
|
+
fn busy_dots(&self) -> String {
|
|
1342
|
+
".".repeat(self.busy_frame / 4)
|
|
1343
|
+
}
|
|
1344
|
+
|
|
986
1345
|
fn mode_hint(&self) -> &'static str {
|
|
1346
|
+
let lang = &self.state.settings.ui_language;
|
|
987
1347
|
match (self.focus, self.list_cursor.is_some(), self.show_output) {
|
|
988
|
-
(Focus::Command, _, _) =>
|
|
989
|
-
(_, true, _) =>
|
|
990
|
-
(_, _, true) =>
|
|
991
|
-
(Focus::Code, _, _) =>
|
|
992
|
-
_ =>
|
|
1348
|
+
(Focus::Command, _, _) => ui_text(lang, "hint_command"),
|
|
1349
|
+
(_, true, _) => ui_text(lang, "hint_list"),
|
|
1350
|
+
(_, _, true) => ui_text(lang, "hint_output"),
|
|
1351
|
+
(Focus::Code, _, _) => ui_text(lang, "hint_code"),
|
|
1352
|
+
_ => ui_text(lang, "hint_idle"),
|
|
993
1353
|
}
|
|
994
1354
|
}
|
|
1355
|
+
|
|
1356
|
+
fn help_text(&self) -> String {
|
|
1357
|
+
let lang = &self.state.settings.ui_language;
|
|
1358
|
+
let commands = COMMAND_HINTS
|
|
1359
|
+
.iter()
|
|
1360
|
+
.filter(|hint| hint.help)
|
|
1361
|
+
.map(|hint| format!("- `{}` {}", hint.display, ui_text(lang, hint.desc_key)))
|
|
1362
|
+
.collect::<Vec<_>>()
|
|
1363
|
+
.join("\n");
|
|
1364
|
+
format!(
|
|
1365
|
+
"# {}\n\n## {}\n\n1. Type code in the right pane.\n2. Press `Esc`, then choose `/run` from the command palette.\n3. Use `/next` when it passes.\n\n## {}\n\n{}\n\n## {}\n\n- `/` opens the command palette outside the editor.\n- `↑/↓` selects a command and `Enter` accepts it.\n- `Esc` cancels the command palette or leaves output.\n\n## {}\n\n- stdout is shown when a case fails.\n- stderr is shown without affecting the expected stdout.",
|
|
1366
|
+
ui_text(lang, "help_title"),
|
|
1367
|
+
ui_text(lang, "daily_loop"),
|
|
1368
|
+
ui_text(lang, "commands"),
|
|
1369
|
+
commands,
|
|
1370
|
+
ui_text(lang, "keys"),
|
|
1371
|
+
ui_text(lang, "debug_prints"),
|
|
1372
|
+
)
|
|
1373
|
+
}
|
|
995
1374
|
}
|
|
996
1375
|
|
|
997
1376
|
#[derive(Clone, Debug)]
|