practicode 0.1.9 → 0.1.11
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 +7 -2
- package/assets/i18n/en.json +4 -1
- package/assets/i18n/es.json +4 -1
- package/assets/i18n/ja.json +4 -1
- package/assets/i18n/ko.json +4 -1
- package/assets/i18n/zh.json +4 -1
- package/docs/ARCHITECTURE.md +20 -4
- package/package.json +1 -1
- package/src/ai.rs +103 -15
- package/src/core/bank.rs +148 -0
- package/src/core/judge.rs +205 -0
- package/src/core/language.rs +110 -0
- package/src/core/model.rs +171 -0
- package/src/core/problem_files.rs +92 -0
- package/src/core/progress.rs +97 -0
- package/src/core/render.rs +119 -0
- package/src/core/state.rs +81 -0
- package/src/core.rs +18 -983
- package/src/tui/actions.rs +377 -0
- package/src/tui/command_handlers.rs +138 -0
- package/src/tui/command_input.rs +120 -0
- package/src/tui/commands.rs +56 -0
- package/src/tui/events.rs +225 -0
- package/src/tui/problem_list.rs +163 -0
- package/src/tui/settings_panel.rs +158 -59
- package/src/tui/status.rs +109 -0
- package/src/tui/tasks.rs +303 -0
- package/src/tui/view.rs +395 -0
- package/src/tui.rs +19 -1613
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
impl PracticodeApp {
|
|
4
|
+
pub(super) fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
5
|
+
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
6
|
+
self.should_quit = true;
|
|
7
|
+
return Ok(());
|
|
8
|
+
}
|
|
9
|
+
if self.handle_busy_key(key) {
|
|
10
|
+
return Ok(());
|
|
11
|
+
}
|
|
12
|
+
if self.editing_notes {
|
|
13
|
+
return self.handle_note_key(key);
|
|
14
|
+
}
|
|
15
|
+
match self.focus {
|
|
16
|
+
Focus::Command => self.handle_command_key(key),
|
|
17
|
+
Focus::Code => self.handle_code_key(key),
|
|
18
|
+
_ => self.handle_global_key(key),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<()> {
|
|
23
|
+
if self.task_rx.is_some() {
|
|
24
|
+
self.focus = Focus::Output;
|
|
25
|
+
return Ok(());
|
|
26
|
+
}
|
|
27
|
+
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
|
28
|
+
return Ok(());
|
|
29
|
+
}
|
|
30
|
+
let position = Position::new(mouse.column, mouse.row);
|
|
31
|
+
if self.command_area.contains(position) {
|
|
32
|
+
self.focus_command();
|
|
33
|
+
} else if self.show_output && self.output_area.contains(position) {
|
|
34
|
+
self.focus = Focus::Output;
|
|
35
|
+
} else if self.code_area.contains(position) {
|
|
36
|
+
self.action_edit()?;
|
|
37
|
+
}
|
|
38
|
+
Ok(())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub(super) fn handle_command_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
42
|
+
match key.code {
|
|
43
|
+
KeyCode::Esc => {
|
|
44
|
+
self.command.clear();
|
|
45
|
+
self.command_cursor = 0;
|
|
46
|
+
self.command_palette_cursor = 0;
|
|
47
|
+
self.focus = Focus::None;
|
|
48
|
+
}
|
|
49
|
+
KeyCode::Enter => {
|
|
50
|
+
if !self.accept_command_palette()? {
|
|
51
|
+
let value = self.command.trim().to_string();
|
|
52
|
+
self.command.clear();
|
|
53
|
+
self.command_cursor = 0;
|
|
54
|
+
self.command_palette_cursor = 0;
|
|
55
|
+
self.focus = Focus::None;
|
|
56
|
+
self.submit_command(&value)?;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
KeyCode::Backspace => self.delete_command_before_cursor(),
|
|
60
|
+
KeyCode::Delete => self.delete_command_at_cursor(),
|
|
61
|
+
KeyCode::Left => self.command_cursor = self.command_cursor.saturating_sub(1),
|
|
62
|
+
KeyCode::Right => {
|
|
63
|
+
self.command_cursor = (self.command_cursor + 1).min(char_len(&self.command));
|
|
64
|
+
}
|
|
65
|
+
KeyCode::Up => self.move_command_palette(-1),
|
|
66
|
+
KeyCode::Down => self.move_command_palette(1),
|
|
67
|
+
KeyCode::Home => self.command_cursor = 0,
|
|
68
|
+
KeyCode::End => self.command_cursor = char_len(&self.command),
|
|
69
|
+
KeyCode::Char('?') if self.command.trim().is_empty() || self.command.trim() == "/" => {
|
|
70
|
+
self.command.clear();
|
|
71
|
+
self.command_cursor = 0;
|
|
72
|
+
self.command_palette_cursor = 0;
|
|
73
|
+
self.focus = Focus::None;
|
|
74
|
+
self.handle_command("help")?;
|
|
75
|
+
}
|
|
76
|
+
KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
77
|
+
self.insert_command_char(char);
|
|
78
|
+
}
|
|
79
|
+
_ => {}
|
|
80
|
+
}
|
|
81
|
+
Ok(())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pub(super) fn handle_code_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
85
|
+
match key.code {
|
|
86
|
+
KeyCode::Esc => self.focus = Focus::None,
|
|
87
|
+
KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
88
|
+
self.editor.insert_char(char);
|
|
89
|
+
self.save_code()?;
|
|
90
|
+
}
|
|
91
|
+
KeyCode::Enter => {
|
|
92
|
+
self.editor.insert_newline();
|
|
93
|
+
self.save_code()?;
|
|
94
|
+
}
|
|
95
|
+
KeyCode::Backspace => {
|
|
96
|
+
self.editor.backspace();
|
|
97
|
+
self.save_code()?;
|
|
98
|
+
}
|
|
99
|
+
KeyCode::Delete => {
|
|
100
|
+
self.editor.delete();
|
|
101
|
+
self.save_code()?;
|
|
102
|
+
}
|
|
103
|
+
KeyCode::Tab => {
|
|
104
|
+
for _ in 0..4 {
|
|
105
|
+
self.editor.insert_char(' ');
|
|
106
|
+
}
|
|
107
|
+
self.save_code()?;
|
|
108
|
+
}
|
|
109
|
+
KeyCode::Left => self.editor.move_left(),
|
|
110
|
+
KeyCode::Right => self.editor.move_right(),
|
|
111
|
+
KeyCode::Up => self.editor.move_up(),
|
|
112
|
+
KeyCode::Down => self.editor.move_down(),
|
|
113
|
+
_ => {}
|
|
114
|
+
}
|
|
115
|
+
Ok(())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pub(super) fn handle_note_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
119
|
+
match key.code {
|
|
120
|
+
KeyCode::Esc => self.close_note_editor()?,
|
|
121
|
+
KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
122
|
+
self.note_editor.insert_char(char);
|
|
123
|
+
self.save_notes()?;
|
|
124
|
+
}
|
|
125
|
+
KeyCode::Enter => {
|
|
126
|
+
self.note_editor.insert_newline();
|
|
127
|
+
self.save_notes()?;
|
|
128
|
+
}
|
|
129
|
+
KeyCode::Backspace => {
|
|
130
|
+
self.note_editor.backspace();
|
|
131
|
+
self.save_notes()?;
|
|
132
|
+
}
|
|
133
|
+
KeyCode::Delete => {
|
|
134
|
+
self.note_editor.delete();
|
|
135
|
+
self.save_notes()?;
|
|
136
|
+
}
|
|
137
|
+
KeyCode::Tab => {
|
|
138
|
+
for _ in 0..4 {
|
|
139
|
+
self.note_editor.insert_char(' ');
|
|
140
|
+
}
|
|
141
|
+
self.save_notes()?;
|
|
142
|
+
}
|
|
143
|
+
KeyCode::Left => self.note_editor.move_left(),
|
|
144
|
+
KeyCode::Right => self.note_editor.move_right(),
|
|
145
|
+
KeyCode::Up => self.note_editor.move_up(),
|
|
146
|
+
KeyCode::Down => self.note_editor.move_down(),
|
|
147
|
+
_ => {}
|
|
148
|
+
}
|
|
149
|
+
Ok(())
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pub(super) fn handle_global_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
153
|
+
if self.settings_cursor.is_some() {
|
|
154
|
+
match key.code {
|
|
155
|
+
KeyCode::Up | KeyCode::Char('k') => self.move_settings_cursor(-1),
|
|
156
|
+
KeyCode::Down | KeyCode::Char('j') => self.move_settings_cursor(1),
|
|
157
|
+
KeyCode::Char(' ') | KeyCode::Enter => self.change_selected_setting()?,
|
|
158
|
+
KeyCode::Esc => {
|
|
159
|
+
self.settings_cursor = None;
|
|
160
|
+
self.show_output = false;
|
|
161
|
+
self.focus = Focus::Code;
|
|
162
|
+
}
|
|
163
|
+
_ => self.handle_global_shortcut(key)?,
|
|
164
|
+
}
|
|
165
|
+
return Ok(());
|
|
166
|
+
}
|
|
167
|
+
if let Some(cursor) = self.list_cursor {
|
|
168
|
+
match key.code {
|
|
169
|
+
KeyCode::Up | KeyCode::Char('k') => self.move_list_cursor(-1),
|
|
170
|
+
KeyCode::Down | KeyCode::Char('j') => self.move_list_cursor(1),
|
|
171
|
+
KeyCode::Enter => self.open_selected_problem()?,
|
|
172
|
+
KeyCode::Esc => {
|
|
173
|
+
self.list_cursor = None;
|
|
174
|
+
self.write_text_output("Closed list.");
|
|
175
|
+
}
|
|
176
|
+
_ => {
|
|
177
|
+
self.list_cursor = Some(cursor);
|
|
178
|
+
self.handle_global_shortcut(key)?;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return Ok(());
|
|
182
|
+
}
|
|
183
|
+
if key.code == KeyCode::Esc && self.show_output {
|
|
184
|
+
self.show_output = false;
|
|
185
|
+
self.focus = Focus::Code;
|
|
186
|
+
return Ok(());
|
|
187
|
+
}
|
|
188
|
+
self.handle_global_shortcut(key)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pub(super) fn handle_global_shortcut(&mut self, key: KeyEvent) -> Result<()> {
|
|
192
|
+
match key.code {
|
|
193
|
+
KeyCode::Char('/') => self.focus_command(),
|
|
194
|
+
KeyCode::Char('?') => self.handle_command("help")?,
|
|
195
|
+
KeyCode::Char('r') => self.action_run()?,
|
|
196
|
+
KeyCode::Char('n') => self.action_next("")?,
|
|
197
|
+
KeyCode::Char('p') => self.action_previous()?,
|
|
198
|
+
KeyCode::Char('g') => self.action_give_up()?,
|
|
199
|
+
KeyCode::Char('e') => self.action_edit()?,
|
|
200
|
+
KeyCode::Char('l') => self.action_cycle_language()?,
|
|
201
|
+
KeyCode::Char('u') => self.action_toggle_ui_language()?,
|
|
202
|
+
KeyCode::Char('q') => self.should_quit = true,
|
|
203
|
+
_ => {}
|
|
204
|
+
}
|
|
205
|
+
Ok(())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub(super) fn focus_command(&mut self) {
|
|
209
|
+
if self.command.is_empty() {
|
|
210
|
+
self.command.push('/');
|
|
211
|
+
self.command_cursor = 1;
|
|
212
|
+
}
|
|
213
|
+
self.command_palette_cursor = 0;
|
|
214
|
+
self.focus = Focus::Command;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
pub(super) fn submit_command(&mut self, value: &str) -> Result<()> {
|
|
218
|
+
let value = value
|
|
219
|
+
.trim()
|
|
220
|
+
.strip_prefix('/')
|
|
221
|
+
.unwrap_or(value.trim())
|
|
222
|
+
.trim();
|
|
223
|
+
self.handle_command(value)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
impl PracticodeApp {
|
|
4
|
+
pub(super) fn load_code_editor(&mut self) -> Result<()> {
|
|
5
|
+
let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?;
|
|
6
|
+
let text = fs::read_to_string(path).unwrap_or_default();
|
|
7
|
+
self.editor.set_text(&text);
|
|
8
|
+
Ok(())
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
pub(super) fn save_code(&self) -> Result<()> {
|
|
12
|
+
let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?;
|
|
13
|
+
fs::write(path, self.editor.text())?;
|
|
14
|
+
Ok(())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub(super) fn start_problem_list(&mut self) {
|
|
18
|
+
self.list_cursor = Some(self.current_problem_index());
|
|
19
|
+
self.write_text_output(&self.render_problem_list());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub(super) fn render_problem_list(&self) -> String {
|
|
23
|
+
let status_by_id = self
|
|
24
|
+
.state
|
|
25
|
+
.history
|
|
26
|
+
.iter()
|
|
27
|
+
.map(|item| (item.id.as_str(), item.status.as_str()))
|
|
28
|
+
.collect::<HashMap<_, _>>();
|
|
29
|
+
let cursor = self
|
|
30
|
+
.list_cursor
|
|
31
|
+
.unwrap_or_else(|| self.current_problem_index());
|
|
32
|
+
let mut lines = vec![
|
|
33
|
+
"Problems".to_string(),
|
|
34
|
+
String::new(),
|
|
35
|
+
" # ID Difficulty Status Code Title".to_string(),
|
|
36
|
+
];
|
|
37
|
+
for (index, problem) in self.bank.iter().enumerate() {
|
|
38
|
+
let marker = if index == cursor { ">" } else { " " };
|
|
39
|
+
let current = if problem.id == self.problem.id {
|
|
40
|
+
"*"
|
|
41
|
+
} else {
|
|
42
|
+
" "
|
|
43
|
+
};
|
|
44
|
+
let title = localized(&problem.title, &self.state.settings.ui_language);
|
|
45
|
+
let code_status = self.submission_status(problem).0;
|
|
46
|
+
lines.push(format!(
|
|
47
|
+
"{marker} {current} {:>2} {:<18} {:<10} {:<10} {:<9} {title}",
|
|
48
|
+
index + 1,
|
|
49
|
+
problem.id,
|
|
50
|
+
problem.difficulty,
|
|
51
|
+
status_by_id
|
|
52
|
+
.get(problem.id.as_str())
|
|
53
|
+
.copied()
|
|
54
|
+
.unwrap_or("-"),
|
|
55
|
+
code_status,
|
|
56
|
+
));
|
|
57
|
+
}
|
|
58
|
+
lines.push("\nup/down or j/k select | enter open | esc close".to_string());
|
|
59
|
+
lines.join("\n")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pub(super) fn current_problem_index(&self) -> usize {
|
|
63
|
+
self.bank
|
|
64
|
+
.iter()
|
|
65
|
+
.position(|problem| problem.id == self.problem.id)
|
|
66
|
+
.unwrap_or(0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub(super) fn move_list_cursor(&mut self, delta: isize) {
|
|
70
|
+
if self.bank.is_empty() {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let cursor = self
|
|
74
|
+
.list_cursor
|
|
75
|
+
.unwrap_or_else(|| self.current_problem_index()) as isize;
|
|
76
|
+
let len = self.bank.len() as isize;
|
|
77
|
+
self.list_cursor = Some(((cursor + delta).rem_euclid(len)) as usize);
|
|
78
|
+
self.write_text_output(&self.render_problem_list());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pub(super) fn open_selected_problem(&mut self) -> Result<()> {
|
|
82
|
+
if let Some(cursor) = self.list_cursor {
|
|
83
|
+
let problem_id = self.bank[cursor].id.clone();
|
|
84
|
+
self.list_cursor = None;
|
|
85
|
+
self.open_problem(&problem_id)?;
|
|
86
|
+
}
|
|
87
|
+
Ok(())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pub(super) fn open_problem(&mut self, query: &str) -> Result<()> {
|
|
91
|
+
self.list_cursor = None;
|
|
92
|
+
let Some(problem) = self.find_problem(query).cloned() else {
|
|
93
|
+
self.write_text_output(&format!("Problem not found: {query}\nTry /problems."));
|
|
94
|
+
return Ok(());
|
|
95
|
+
};
|
|
96
|
+
self.problem = problem;
|
|
97
|
+
self.state.current_problem = self.problem.id.clone();
|
|
98
|
+
if !self
|
|
99
|
+
.state
|
|
100
|
+
.history
|
|
101
|
+
.iter()
|
|
102
|
+
.any(|item| item.id == self.problem.id)
|
|
103
|
+
{
|
|
104
|
+
self.state.history.push(HistoryItem {
|
|
105
|
+
id: self.problem.id.clone(),
|
|
106
|
+
status: "assigned".to_string(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
save_state(&self.root, &self.state)?;
|
|
110
|
+
ensure_problem_files(&self.root, &self.problem)?;
|
|
111
|
+
self.load_code_editor()?;
|
|
112
|
+
self.show_output = false;
|
|
113
|
+
self.focus = Focus::Code;
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pub(super) fn find_problem(&self, query: &str) -> Option<&Problem> {
|
|
118
|
+
let needle = if query.trim().chars().all(|c| c.is_ascii_digit()) {
|
|
119
|
+
format!("{:03}", query.trim().parse::<usize>().ok()?)
|
|
120
|
+
} else {
|
|
121
|
+
query.trim().to_lowercase()
|
|
122
|
+
};
|
|
123
|
+
self.bank.iter().find(|problem| {
|
|
124
|
+
needle == problem.id.to_lowercase()
|
|
125
|
+
|| needle == problem.slug.to_lowercase()
|
|
126
|
+
|| problem.id.starts_with(&needle)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pub(super) fn problem_status(&self, problem: &Problem) -> String {
|
|
131
|
+
if self.state.solved.contains(&problem.id) {
|
|
132
|
+
return "solved".to_string();
|
|
133
|
+
}
|
|
134
|
+
self.state
|
|
135
|
+
.history
|
|
136
|
+
.iter()
|
|
137
|
+
.rev()
|
|
138
|
+
.find(|item| item.id == problem.id)
|
|
139
|
+
.map(|item| item.status.clone())
|
|
140
|
+
.unwrap_or_else(|| "not_started".to_string())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub(super) fn submission_status(&self, problem: &Problem) -> (String, String) {
|
|
144
|
+
let language = normalize_language(&self.state.settings.language);
|
|
145
|
+
let path = self
|
|
146
|
+
.root
|
|
147
|
+
.join("submissions")
|
|
148
|
+
.join(&problem.id)
|
|
149
|
+
.join(format!("solution.{}", ext_for(&language)));
|
|
150
|
+
if !path.exists() {
|
|
151
|
+
return ("missing".to_string(), format!("({language})"));
|
|
152
|
+
}
|
|
153
|
+
let content = fs::read_to_string(&path).unwrap_or_default();
|
|
154
|
+
let relative = path.strip_prefix(&self.root).unwrap_or(&path).display();
|
|
155
|
+
if content == template_for(&language) {
|
|
156
|
+
("template".to_string(), format!("({relative})"))
|
|
157
|
+
} else if content.trim().is_empty() {
|
|
158
|
+
("empty".to_string(), format!("({relative})"))
|
|
159
|
+
} else {
|
|
160
|
+
("written".to_string(), format!("({relative})"))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -1,14 +1,30 @@
|
|
|
1
|
-
use crate::core::{
|
|
1
|
+
use crate::core::{
|
|
2
|
+
AI_PROVIDERS, AppState, CLAUDE_AI_EFFORTS, CODEX_AI_EFFORTS, DIFFICULTIES, LANGUAGES, THEMES,
|
|
3
|
+
UI_LANGUAGES, normalize_ai_effort,
|
|
4
|
+
};
|
|
2
5
|
|
|
3
6
|
pub(super) struct SettingsChange {
|
|
4
7
|
pub reload_editor: bool,
|
|
8
|
+
pub provider_changed: bool,
|
|
9
|
+
pub edit_notes: bool,
|
|
5
10
|
}
|
|
6
11
|
|
|
12
|
+
const AI_PROVIDER_ROW: usize = 4;
|
|
13
|
+
pub(super) const AI_MODEL_ROW: usize = 5;
|
|
14
|
+
const AI_EFFORT_ROW: usize = 6;
|
|
15
|
+
const NOTE_ROW: usize = 7;
|
|
16
|
+
const TOGGLE_START: usize = 8;
|
|
17
|
+
|
|
7
18
|
pub(super) fn row_count() -> usize {
|
|
8
|
-
|
|
19
|
+
TOGGLE_START + LANGUAGES.len() + UI_LANGUAGES.len()
|
|
9
20
|
}
|
|
10
21
|
|
|
11
|
-
pub(super) fn render(
|
|
22
|
+
pub(super) fn render(
|
|
23
|
+
state: &AppState,
|
|
24
|
+
cursor: Option<usize>,
|
|
25
|
+
available_models: &[String],
|
|
26
|
+
models_loading: bool,
|
|
27
|
+
) -> String {
|
|
12
28
|
let settings = &state.settings;
|
|
13
29
|
let ui_language = settings.ui_language.as_str();
|
|
14
30
|
let topics = list_or_none(&settings.topics, ui_language);
|
|
@@ -63,20 +79,52 @@ pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
|
|
|
63
79
|
"{}: {generate_ui_languages}",
|
|
64
80
|
label(ui_language, "generated_ui_languages")
|
|
65
81
|
),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
row(
|
|
83
|
+
cursor,
|
|
84
|
+
AI_PROVIDER_ROW,
|
|
85
|
+
&format!("AI provider: {}", settings.ai_provider),
|
|
86
|
+
),
|
|
87
|
+
row(
|
|
88
|
+
cursor,
|
|
89
|
+
AI_MODEL_ROW,
|
|
90
|
+
&format!(
|
|
91
|
+
"AI model: {}{}",
|
|
92
|
+
if settings.ai_model == "auto" {
|
|
93
|
+
label(ui_language, "provider_default")
|
|
94
|
+
} else {
|
|
95
|
+
settings.ai_model.as_str()
|
|
96
|
+
},
|
|
97
|
+
if models_loading {
|
|
98
|
+
" (loading)"
|
|
99
|
+
} else if available_models.is_empty() {
|
|
100
|
+
" (/model to load)"
|
|
101
|
+
} else {
|
|
102
|
+
""
|
|
103
|
+
}
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
row(
|
|
107
|
+
cursor,
|
|
108
|
+
AI_EFFORT_ROW,
|
|
109
|
+
&format!(
|
|
110
|
+
"AI effort: {}",
|
|
111
|
+
if settings.ai_effort == "auto" {
|
|
112
|
+
label(ui_language, "provider_default")
|
|
113
|
+
} else {
|
|
114
|
+
settings.ai_effort.as_str()
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
row(
|
|
119
|
+
cursor,
|
|
120
|
+
NOTE_ROW,
|
|
121
|
+
&format!("{}: Enter", label(ui_language, "problem_notes")),
|
|
74
122
|
),
|
|
75
123
|
String::new(),
|
|
76
124
|
label(ui_language, "answer_toggles").to_string(),
|
|
77
125
|
];
|
|
78
126
|
for (index, language) in LANGUAGES.iter().enumerate() {
|
|
79
|
-
let row_index =
|
|
127
|
+
let row_index = TOGGLE_START + index;
|
|
80
128
|
let checked = generate_language_enabled(state, language);
|
|
81
129
|
lines.push(row(
|
|
82
130
|
cursor,
|
|
@@ -87,7 +135,7 @@ pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
|
|
|
87
135
|
lines.push(String::new());
|
|
88
136
|
lines.push(label(ui_language, "ui_toggles").to_string());
|
|
89
137
|
for (index, language) in UI_LANGUAGES.iter().enumerate() {
|
|
90
|
-
let row_index =
|
|
138
|
+
let row_index = TOGGLE_START + LANGUAGES.len() + index;
|
|
91
139
|
let checked = generate_ui_language_enabled(state, language);
|
|
92
140
|
lines.push(row(
|
|
93
141
|
cursor,
|
|
@@ -106,10 +154,84 @@ pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
|
|
|
106
154
|
"/generate-ui all|en, ko".to_string(),
|
|
107
155
|
"/provider codex|claude".to_string(),
|
|
108
156
|
"/model auto".to_string(),
|
|
157
|
+
"/effort auto|low|medium|high|xhigh|max".to_string(),
|
|
158
|
+
"/note".to_string(),
|
|
159
|
+
"/notes".to_string(),
|
|
109
160
|
]);
|
|
110
161
|
lines.join("\n")
|
|
111
162
|
}
|
|
112
163
|
|
|
164
|
+
pub(super) fn apply_selected(
|
|
165
|
+
state: &mut AppState,
|
|
166
|
+
selected: usize,
|
|
167
|
+
available_models: &[String],
|
|
168
|
+
) -> SettingsChange {
|
|
169
|
+
let mut change = SettingsChange {
|
|
170
|
+
reload_editor: false,
|
|
171
|
+
provider_changed: false,
|
|
172
|
+
edit_notes: false,
|
|
173
|
+
};
|
|
174
|
+
match selected {
|
|
175
|
+
0 => {
|
|
176
|
+
let current = LANGUAGES
|
|
177
|
+
.iter()
|
|
178
|
+
.position(|language| language == &state.settings.language)
|
|
179
|
+
.unwrap_or(0);
|
|
180
|
+
state.settings.language = LANGUAGES[(current + 1) % LANGUAGES.len()].to_string();
|
|
181
|
+
change.reload_editor = true;
|
|
182
|
+
}
|
|
183
|
+
1 => {
|
|
184
|
+
let current = UI_LANGUAGES
|
|
185
|
+
.iter()
|
|
186
|
+
.position(|language| language == &state.settings.ui_language)
|
|
187
|
+
.unwrap_or(0);
|
|
188
|
+
state.settings.ui_language =
|
|
189
|
+
UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()].to_string();
|
|
190
|
+
}
|
|
191
|
+
2 => {
|
|
192
|
+
let current = THEMES
|
|
193
|
+
.iter()
|
|
194
|
+
.position(|theme| theme == &state.settings.theme)
|
|
195
|
+
.unwrap_or(0);
|
|
196
|
+
state.settings.theme = THEMES[(current + 1) % THEMES.len()].to_string();
|
|
197
|
+
}
|
|
198
|
+
3 => {
|
|
199
|
+
let current = DIFFICULTIES
|
|
200
|
+
.iter()
|
|
201
|
+
.position(|difficulty| difficulty == &state.settings.difficulty)
|
|
202
|
+
.unwrap_or(0);
|
|
203
|
+
let difficulty = DIFFICULTIES[(current + 1) % DIFFICULTIES.len()].to_string();
|
|
204
|
+
state.settings.difficulty = difficulty.clone();
|
|
205
|
+
if difficulty != "auto" {
|
|
206
|
+
state.suggested_next_difficulty = difficulty;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
AI_PROVIDER_ROW => {
|
|
210
|
+
let current = AI_PROVIDERS
|
|
211
|
+
.iter()
|
|
212
|
+
.position(|provider| provider == &state.settings.ai_provider)
|
|
213
|
+
.unwrap_or(0);
|
|
214
|
+
state.settings.ai_provider =
|
|
215
|
+
AI_PROVIDERS[(current + 1) % AI_PROVIDERS.len()].to_string();
|
|
216
|
+
state.settings.ai_model = "auto".to_string();
|
|
217
|
+
state.settings.ai_effort =
|
|
218
|
+
normalize_ai_effort(&state.settings.ai_provider, &state.settings.ai_effort);
|
|
219
|
+
change.provider_changed = true;
|
|
220
|
+
}
|
|
221
|
+
AI_MODEL_ROW => cycle_ai_model(state, available_models),
|
|
222
|
+
AI_EFFORT_ROW => cycle_ai_effort(state),
|
|
223
|
+
NOTE_ROW => change.edit_notes = true,
|
|
224
|
+
row if row < TOGGLE_START + LANGUAGES.len() => {
|
|
225
|
+
toggle_generate_language(state, LANGUAGES[row - TOGGLE_START]);
|
|
226
|
+
}
|
|
227
|
+
row if row < row_count() => {
|
|
228
|
+
toggle_generate_ui_language(state, UI_LANGUAGES[row - TOGGLE_START - LANGUAGES.len()]);
|
|
229
|
+
}
|
|
230
|
+
_ => {}
|
|
231
|
+
}
|
|
232
|
+
change
|
|
233
|
+
}
|
|
234
|
+
|
|
113
235
|
fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
114
236
|
if ui_language == "ko" {
|
|
115
237
|
match key {
|
|
@@ -124,6 +246,7 @@ fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
|
124
246
|
"generated_answer_languages" => "생성 정답 언어",
|
|
125
247
|
"generated_ui_languages" => "생성 문제 언어",
|
|
126
248
|
"provider_default" => "auto (provider 기본값)",
|
|
249
|
+
"problem_notes" => "문제 생성 메모 편집",
|
|
127
250
|
"answer_toggles" => "생성 정답 언어 토글",
|
|
128
251
|
"ui_toggles" => "생성 문제 언어 토글",
|
|
129
252
|
"commands" => "명령",
|
|
@@ -144,6 +267,7 @@ fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
|
144
267
|
"generated_answer_languages" => "Generated answer languages",
|
|
145
268
|
"generated_ui_languages" => "Generated UI languages",
|
|
146
269
|
"provider_default" => "auto (provider default)",
|
|
270
|
+
"problem_notes" => "Edit problem notes",
|
|
147
271
|
"answer_toggles" => "Generated answer language toggles",
|
|
148
272
|
"ui_toggles" => "Generated problem text language toggles",
|
|
149
273
|
"commands" => "Commands",
|
|
@@ -154,52 +278,27 @@ fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
|
154
278
|
}
|
|
155
279
|
}
|
|
156
280
|
|
|
157
|
-
|
|
158
|
-
let mut
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.iter()
|
|
179
|
-
.position(|theme| theme == &state.settings.theme)
|
|
180
|
-
.unwrap_or(0);
|
|
181
|
-
state.settings.theme = THEMES[(current + 1) % THEMES.len()].to_string();
|
|
182
|
-
}
|
|
183
|
-
3 => {
|
|
184
|
-
let current = DIFFICULTIES
|
|
185
|
-
.iter()
|
|
186
|
-
.position(|difficulty| difficulty == &state.settings.difficulty)
|
|
187
|
-
.unwrap_or(0);
|
|
188
|
-
let difficulty = DIFFICULTIES[(current + 1) % DIFFICULTIES.len()].to_string();
|
|
189
|
-
state.settings.difficulty = difficulty.clone();
|
|
190
|
-
if difficulty != "auto" {
|
|
191
|
-
state.suggested_next_difficulty = difficulty;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
row if row < 4 + LANGUAGES.len() => {
|
|
195
|
-
toggle_generate_language(state, LANGUAGES[row - 4]);
|
|
196
|
-
}
|
|
197
|
-
row if row < row_count() => {
|
|
198
|
-
toggle_generate_ui_language(state, UI_LANGUAGES[row - 4 - LANGUAGES.len()]);
|
|
199
|
-
}
|
|
200
|
-
_ => {}
|
|
201
|
-
}
|
|
202
|
-
SettingsChange { reload_editor }
|
|
281
|
+
fn cycle_ai_model(state: &mut AppState, available_models: &[String]) {
|
|
282
|
+
let mut models = vec!["auto"];
|
|
283
|
+
models.extend(available_models.iter().map(String::as_str));
|
|
284
|
+
let current = models
|
|
285
|
+
.iter()
|
|
286
|
+
.position(|model| model == &state.settings.ai_model)
|
|
287
|
+
.unwrap_or(0);
|
|
288
|
+
state.settings.ai_model = models[(current + 1) % models.len()].to_string();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
fn cycle_ai_effort(state: &mut AppState) {
|
|
292
|
+
let efforts = if state.settings.ai_provider == "claude" {
|
|
293
|
+
CLAUDE_AI_EFFORTS
|
|
294
|
+
} else {
|
|
295
|
+
CODEX_AI_EFFORTS
|
|
296
|
+
};
|
|
297
|
+
let current = efforts
|
|
298
|
+
.iter()
|
|
299
|
+
.position(|effort| effort == &state.settings.ai_effort)
|
|
300
|
+
.unwrap_or(0);
|
|
301
|
+
state.settings.ai_effort = efforts[(current + 1) % efforts.len()].to_string();
|
|
203
302
|
}
|
|
204
303
|
|
|
205
304
|
fn row(cursor: Option<usize>, index: usize, text: &str) -> String {
|