practicode 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.rs CHANGED
@@ -4,11 +4,12 @@ use crate::{
4
4
  run_ai_next, run_ai_prompt,
5
5
  },
6
6
  core::{
7
- AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
8
- UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
9
- load_state, localized, next_problem, normalize_ai_provider, normalize_language,
10
- normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
11
- save_state, template_for, ui_text,
7
+ AI_PROVIDERS, AppState, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem,
8
+ THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge,
9
+ load_bank, load_state, localized, next_problem, normalize_ai_provider,
10
+ normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language,
11
+ parse_topic_list, previous_problem, problem_by_id, record_pass, save_state, template_for,
12
+ ui_text,
12
13
  },
13
14
  text::{
14
15
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -33,17 +34,13 @@ use std::{
33
34
  path::PathBuf,
34
35
  sync::mpsc::{self, Receiver},
35
36
  thread,
36
- time::Duration,
37
+ time::{Duration, Instant},
37
38
  };
38
39
 
39
- #[derive(Clone, Copy)]
40
- struct CommandHint {
41
- insert: &'static str,
42
- display: &'static str,
43
- desc_key: &'static str,
44
- keep_open: bool,
45
- help: bool,
46
- }
40
+ mod commands;
41
+ use self::commands::COMMAND_HINTS;
42
+
43
+ const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
47
44
 
48
45
  #[derive(Clone)]
49
46
  struct CommandChoice {
@@ -53,205 +50,6 @@ struct CommandChoice {
53
50
  keep_open: bool,
54
51
  }
55
52
 
56
- const COMMAND_HINTS: &[CommandHint] = &[
57
- CommandHint {
58
- insert: "run",
59
- display: "/run",
60
- desc_key: "cmd_run",
61
- keep_open: false,
62
- help: true,
63
- },
64
- CommandHint {
65
- insert: "code",
66
- display: "/code",
67
- desc_key: "cmd_code",
68
- keep_open: false,
69
- help: true,
70
- },
71
- CommandHint {
72
- insert: "next",
73
- display: "/next",
74
- desc_key: "cmd_next",
75
- keep_open: false,
76
- help: true,
77
- },
78
- CommandHint {
79
- insert: "prev",
80
- display: "/prev",
81
- desc_key: "cmd_prev",
82
- keep_open: false,
83
- help: true,
84
- },
85
- CommandHint {
86
- insert: "list",
87
- display: "/list",
88
- desc_key: "cmd_list",
89
- keep_open: false,
90
- help: true,
91
- },
92
- CommandHint {
93
- insert: "open ",
94
- display: "/open <id>",
95
- desc_key: "cmd_open",
96
- keep_open: true,
97
- help: true,
98
- },
99
- CommandHint {
100
- insert: "giveup",
101
- display: "/giveup",
102
- desc_key: "cmd_giveup",
103
- keep_open: false,
104
- help: true,
105
- },
106
- CommandHint {
107
- insert: "hint ",
108
- display: "/hint <request>",
109
- desc_key: "cmd_hint",
110
- keep_open: true,
111
- help: true,
112
- },
113
- CommandHint {
114
- insert: "provider codex",
115
- display: "/provider codex",
116
- desc_key: "cmd_provider",
117
- keep_open: false,
118
- help: true,
119
- },
120
- CommandHint {
121
- insert: "provider claude",
122
- display: "/provider claude",
123
- desc_key: "cmd_provider",
124
- keep_open: false,
125
- help: false,
126
- },
127
- CommandHint {
128
- insert: "model auto",
129
- display: "/model auto",
130
- desc_key: "cmd_model_auto",
131
- keep_open: false,
132
- help: true,
133
- },
134
- CommandHint {
135
- insert: "model ",
136
- display: "/model <name>",
137
- desc_key: "cmd_model_custom",
138
- keep_open: true,
139
- help: false,
140
- },
141
- CommandHint {
142
- insert: "note ",
143
- display: "/note <text>",
144
- desc_key: "cmd_note",
145
- keep_open: true,
146
- help: true,
147
- },
148
- CommandHint {
149
- insert: "notes",
150
- display: "/notes",
151
- desc_key: "cmd_notes",
152
- keep_open: false,
153
- help: true,
154
- },
155
- CommandHint {
156
- insert: "lang python",
157
- display: "/lang python",
158
- desc_key: "cmd_lang",
159
- keep_open: false,
160
- help: true,
161
- },
162
- CommandHint {
163
- insert: "lang ts",
164
- display: "/lang ts",
165
- desc_key: "cmd_lang",
166
- keep_open: false,
167
- help: false,
168
- },
169
- CommandHint {
170
- insert: "lang java",
171
- display: "/lang java",
172
- desc_key: "cmd_lang",
173
- keep_open: false,
174
- help: false,
175
- },
176
- CommandHint {
177
- insert: "lang rust",
178
- display: "/lang rust",
179
- desc_key: "cmd_lang",
180
- keep_open: false,
181
- help: false,
182
- },
183
- CommandHint {
184
- insert: "ui en",
185
- display: "/ui en",
186
- desc_key: "cmd_ui",
187
- keep_open: false,
188
- help: true,
189
- },
190
- CommandHint {
191
- insert: "ui ko",
192
- display: "/ui ko",
193
- desc_key: "cmd_ui",
194
- keep_open: false,
195
- help: false,
196
- },
197
- CommandHint {
198
- insert: "ui ja",
199
- display: "/ui ja",
200
- desc_key: "cmd_ui",
201
- keep_open: false,
202
- help: false,
203
- },
204
- CommandHint {
205
- insert: "ui zh",
206
- display: "/ui zh",
207
- desc_key: "cmd_ui",
208
- keep_open: false,
209
- help: false,
210
- },
211
- CommandHint {
212
- insert: "ui es",
213
- display: "/ui es",
214
- desc_key: "cmd_ui",
215
- keep_open: false,
216
- help: false,
217
- },
218
- CommandHint {
219
- insert: "theme dark",
220
- display: "/theme dark",
221
- desc_key: "cmd_theme",
222
- keep_open: false,
223
- help: true,
224
- },
225
- CommandHint {
226
- insert: "theme light",
227
- display: "/theme light",
228
- desc_key: "cmd_theme",
229
- keep_open: false,
230
- help: false,
231
- },
232
- CommandHint {
233
- insert: "update",
234
- display: "/update",
235
- desc_key: "cmd_update",
236
- keep_open: false,
237
- help: true,
238
- },
239
- CommandHint {
240
- insert: "help",
241
- display: "/help",
242
- desc_key: "cmd_help",
243
- keep_open: false,
244
- help: true,
245
- },
246
- CommandHint {
247
- insert: "exit",
248
- display: "/exit",
249
- desc_key: "cmd_exit",
250
- keep_open: false,
251
- help: true,
252
- },
253
- ];
254
-
255
53
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
256
54
  enum Focus {
257
55
  Code,
@@ -286,6 +84,7 @@ pub struct PracticodeApp {
286
84
  model_message: Option<String>,
287
85
  update_check: Option<UpdateCheck>,
288
86
  update_notice: Option<String>,
87
+ last_update_check: Option<Instant>,
289
88
  code_area: Rect,
290
89
  output_area: Rect,
291
90
  command_area: Rect,
@@ -297,7 +96,7 @@ enum TaskResult {
297
96
  Next {
298
97
  output: String,
299
98
  old_problem: String,
300
- force: bool,
99
+ fallback_to_local: bool,
301
100
  },
302
101
  }
303
102
 
@@ -334,6 +133,7 @@ impl PracticodeApp {
334
133
  model_message: None,
335
134
  update_check: None,
336
135
  update_notice: None,
136
+ last_update_check: None,
337
137
  code_area: Rect::default(),
338
138
  output_area: Rect::default(),
339
139
  command_area: Rect::default(),
@@ -350,6 +150,7 @@ impl PracticodeApp {
350
150
  terminal.draw(|frame| self.draw(frame))?;
351
151
  self.check_task();
352
152
  self.check_update();
153
+ self.maybe_start_periodic_update_check();
353
154
  self.start_model_check();
354
155
  self.check_models();
355
156
  if event::poll(Duration::from_millis(100))? {
@@ -360,7 +161,7 @@ impl PracticodeApp {
360
161
  }
361
162
  }
362
163
  if !self.busy_label.is_empty() {
363
- self.busy_frame = (self.busy_frame + 1) % 16;
164
+ self.busy_frame = (self.busy_frame + 1) % 32;
364
165
  }
365
166
  }
366
167
  self.save_code().ok();
@@ -409,6 +210,10 @@ impl PracticodeApp {
409
210
  self.task_rx.is_some()
410
211
  }
411
212
 
213
+ pub fn should_quit_for_test(&self) -> bool {
214
+ self.should_quit
215
+ }
216
+
412
217
  pub fn status_text_for_test(&self) -> String {
413
218
  self.status_text()
414
219
  }
@@ -801,6 +606,10 @@ impl PracticodeApp {
801
606
  }
802
607
 
803
608
  fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
609
+ if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
610
+ self.should_quit = true;
611
+ return Ok(());
612
+ }
804
613
  match self.focus {
805
614
  Focus::Command => self.handle_command_key(key),
806
615
  Focus::Code => self.handle_code_key(key),
@@ -971,23 +780,32 @@ impl PracticodeApp {
971
780
  }
972
781
  let (command, arg) = value.split_once(char::is_whitespace).unwrap_or((value, ""));
973
782
  let arg = arg.trim();
974
- if command != "list" {
783
+ if !matches!(command, "list" | "problems") {
975
784
  self.list_cursor = None;
976
785
  }
977
786
  match command {
978
787
  "run" | "r" => self.action_run()?,
979
788
  "code" | "edit" | "e" => self.action_edit()?,
980
789
  "next" | "n" => self.action_next(arg)?,
981
- "prev" | "previous" | "p" => self.action_previous()?,
982
- "giveup" | "give" | "g" => self.action_give_up()?,
983
- "list" => self.start_problem_list(),
790
+ "generate" | "gen" | "new" => self.action_generate(arg),
791
+ "back" | "prev" | "previous" | "p" => self.action_previous()?,
792
+ "answer" | "giveup" | "give" | "g" => self.action_give_up()?,
793
+ "problems" | "list" => self.start_problem_list(),
984
794
  "open" | "o" if !arg.is_empty() => self.open_problem(arg)?,
985
- "lang" if arg.is_empty() => self.action_cycle_language()?,
986
- "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
795
+ "language" | "lang" if arg.is_empty() => self.action_cycle_language()?,
796
+ "language" | "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
987
797
  "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
988
798
  "ui" => self.set_ui_language(&normalize_ui_language(arg))?,
989
799
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
990
800
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
801
+ "profile" | "settings" if arg.is_empty() => self.show_profile(),
802
+ "profile" | "settings" if arg == "reset" => self.reset_profile()?,
803
+ "difficulty" | "level" if arg.is_empty() => self.show_profile(),
804
+ "difficulty" | "level" => self.set_difficulty(arg)?,
805
+ "topics" | "topic" if arg.is_empty() => self.show_profile(),
806
+ "topics" | "topic" => self.set_topics(arg, false)?,
807
+ "avoid" | "skip" if arg.is_empty() => self.show_profile(),
808
+ "avoid" | "skip" => self.set_topics(arg, true)?,
991
809
  "source" | "next-source" if arg.is_empty() => {
992
810
  self.write_text_output(&self.next_source_help());
993
811
  }
@@ -1044,7 +862,7 @@ impl PracticodeApp {
1044
862
  "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
1045
863
  "note" if !arg.is_empty() => self.append_note(arg)?,
1046
864
  "note" | "notes" => self.show_notes()?,
1047
- "update" => self.show_update_notice(),
865
+ "update" => self.refresh_update_notice(),
1048
866
  "exit" | "quit" | "q" => self.should_quit = true,
1049
867
  _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
1050
868
  }
@@ -1082,14 +900,6 @@ impl PracticodeApp {
1082
900
  fn action_next(&mut self, request: &str) -> Result<()> {
1083
901
  let request = request.trim();
1084
902
  let old_problem = self.state.current_problem.clone();
1085
- if !request.is_empty() {
1086
- self.start_next_problem(old_problem, true, request.to_string());
1087
- return Ok(());
1088
- }
1089
- if self.state.settings.next_source == "ai" {
1090
- self.start_next_problem(old_problem, false, String::new());
1091
- return Ok(());
1092
- }
1093
903
  if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
1094
904
  self.problem = problem;
1095
905
  self.load_code_editor()?;
@@ -1097,11 +907,24 @@ impl PracticodeApp {
1097
907
  self.focus = Focus::Code;
1098
908
  return Ok(());
1099
909
  }
1100
- self.start_next_problem(old_problem, true, String::new());
910
+ self.start_next_problem(old_problem, true, request.to_string());
1101
911
  Ok(())
1102
912
  }
1103
913
 
1104
- fn start_next_problem(&mut self, old_problem: String, force: bool, request: String) {
914
+ fn action_generate(&mut self, request: &str) {
915
+ self.start_next_problem(
916
+ self.state.current_problem.clone(),
917
+ false,
918
+ request.trim().to_string(),
919
+ );
920
+ }
921
+
922
+ fn start_next_problem(
923
+ &mut self,
924
+ old_problem: String,
925
+ fallback_to_local: bool,
926
+ request: String,
927
+ ) {
1105
928
  if self.task_rx.is_some() {
1106
929
  self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
1107
930
  return;
@@ -1114,11 +937,11 @@ impl PracticodeApp {
1114
937
  let state = self.state.clone();
1115
938
  let (tx, rx) = mpsc::channel();
1116
939
  thread::spawn(move || {
1117
- let output = run_ai_next(&root, &state, force, &request);
940
+ let output = run_ai_next(&root, &state, true, &request);
1118
941
  let _ = tx.send(TaskResult::Next {
1119
942
  output,
1120
943
  old_problem,
1121
- force,
944
+ fallback_to_local,
1122
945
  });
1123
946
  });
1124
947
  self.task_rx = Some(rx);
@@ -1128,17 +951,17 @@ impl PracticodeApp {
1128
951
  &mut self,
1129
952
  output: String,
1130
953
  old_problem: String,
1131
- force: bool,
954
+ fallback_to_local: bool,
1132
955
  ) -> Result<()> {
1133
- if self.state.settings.next_source == "ai" || force {
1134
- self.bank = load_bank(&self.root)?;
1135
- self.state = load_state(&self.root, &self.bank)?;
1136
- }
956
+ self.bank = load_bank(&self.root)?;
957
+ self.state = load_state(&self.root, &self.bank)?;
1137
958
  self.problem = problem_by_id(&self.bank, &self.state.current_problem)
1138
959
  .cloned()
1139
960
  .unwrap_or_else(|| self.bank[0].clone());
1140
961
  if self.state.current_problem == old_problem {
1141
- if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
962
+ if fallback_to_local
963
+ && let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)?
964
+ {
1142
965
  self.problem = problem;
1143
966
  } else {
1144
967
  self.write_text_output(&format!(
@@ -1225,6 +1048,68 @@ impl PracticodeApp {
1225
1048
  Ok(())
1226
1049
  }
1227
1050
 
1051
+ fn set_difficulty(&mut self, difficulty: &str) -> Result<()> {
1052
+ let difficulty = difficulty.trim().to_lowercase();
1053
+ if !DIFFICULTIES.contains(&difficulty.as_str()) {
1054
+ self.write_text_output("Difficulty: auto, easy, medium, or hard.");
1055
+ return Ok(());
1056
+ }
1057
+ let normalized = normalize_difficulty(&difficulty);
1058
+ self.state.settings.difficulty = normalized.clone();
1059
+ if normalized != "auto" {
1060
+ self.state.suggested_next_difficulty = normalized;
1061
+ }
1062
+ save_state(&self.root, &self.state)?;
1063
+ self.show_profile();
1064
+ Ok(())
1065
+ }
1066
+
1067
+ fn set_topics(&mut self, topics: &str, avoid: bool) -> Result<()> {
1068
+ let topics = parse_topic_list(topics);
1069
+ if avoid {
1070
+ self.state.settings.avoid_topics = topics;
1071
+ } else {
1072
+ self.state.settings.topics = topics;
1073
+ }
1074
+ save_state(&self.root, &self.state)?;
1075
+ self.show_profile();
1076
+ Ok(())
1077
+ }
1078
+
1079
+ fn reset_profile(&mut self) -> Result<()> {
1080
+ self.state.settings.difficulty = "auto".to_string();
1081
+ self.state.settings.topics.clear();
1082
+ self.state.settings.avoid_topics.clear();
1083
+ save_state(&self.root, &self.state)?;
1084
+ self.show_profile();
1085
+ Ok(())
1086
+ }
1087
+
1088
+ fn show_profile(&mut self) {
1089
+ self.write_text_output(&self.profile_text());
1090
+ }
1091
+
1092
+ fn profile_text(&self) -> String {
1093
+ let settings = &self.state.settings;
1094
+ let topics = list_or_none(&settings.topics);
1095
+ let avoid = list_or_none(&settings.avoid_topics);
1096
+ format!(
1097
+ "Practice profile\n\nUI language: {}\nCode language: {}\nTheme: {}\nDifficulty: {}\nPreferred topics: {}\nAvoid topics: {}\nAI provider: {}\nAI model: {}\n\nCommands\n/profile\n/difficulty auto|easy|medium|hard\n/topics arrays, strings\n/avoid dp, graph\n/language python|ts|java|rust\n/ui en|ko|ja|zh|es\n/theme dark|light",
1098
+ settings.ui_language,
1099
+ settings.language,
1100
+ settings.theme,
1101
+ settings.difficulty,
1102
+ topics,
1103
+ avoid,
1104
+ settings.ai_provider,
1105
+ if settings.ai_model == "auto" {
1106
+ "auto (provider default)"
1107
+ } else {
1108
+ settings.ai_model.as_str()
1109
+ }
1110
+ )
1111
+ }
1112
+
1228
1113
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
1229
1114
  if self.task_rx.is_some() {
1230
1115
  self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
@@ -1256,9 +1141,11 @@ impl PracticodeApp {
1256
1141
  TaskResult::Next {
1257
1142
  output,
1258
1143
  old_problem,
1259
- force,
1144
+ fallback_to_local,
1260
1145
  } => {
1261
- if let Err(error) = self.finish_next_problem(output, old_problem, force) {
1146
+ if let Err(error) =
1147
+ self.finish_next_problem(output, old_problem, fallback_to_local)
1148
+ {
1262
1149
  self.write_text_output(&format!("Next failed\n{error}"));
1263
1150
  }
1264
1151
  }
@@ -1271,8 +1158,10 @@ impl PracticodeApp {
1271
1158
  if let Some(result) = result {
1272
1159
  self.update_rx = None;
1273
1160
  self.update_check = Some(result.clone());
1274
- if let UpdateCheck::Available(version) = &result {
1275
- self.update_notice = Some(version.clone());
1161
+ match &result {
1162
+ UpdateCheck::Available(version) => self.update_notice = Some(version.clone()),
1163
+ UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None,
1164
+ UpdateCheck::Failed => {}
1276
1165
  }
1277
1166
  }
1278
1167
  }
@@ -1281,6 +1170,7 @@ impl PracticodeApp {
1281
1170
  if self.update_rx.is_some() {
1282
1171
  return;
1283
1172
  }
1173
+ self.last_update_check = Some(Instant::now());
1284
1174
  let (tx, rx) = mpsc::channel();
1285
1175
  thread::spawn(move || {
1286
1176
  let _ = tx.send(check_latest_version());
@@ -1288,6 +1178,18 @@ impl PracticodeApp {
1288
1178
  self.update_rx = Some(rx);
1289
1179
  }
1290
1180
 
1181
+ fn maybe_start_periodic_update_check(&mut self) {
1182
+ if self.update_rx.is_some() {
1183
+ return;
1184
+ }
1185
+ if self
1186
+ .last_update_check
1187
+ .is_none_or(|last| last.elapsed() >= UPDATE_CHECK_INTERVAL)
1188
+ {
1189
+ self.start_update_check();
1190
+ }
1191
+ }
1192
+
1291
1193
  fn start_model_check(&mut self) {
1292
1194
  let provider = self.state.settings.ai_provider.clone();
1293
1195
  if self.model_rx.is_some() || self.available_models_provider == provider {
@@ -1387,6 +1289,13 @@ impl PracticodeApp {
1387
1289
  self.focus = Focus::Output;
1388
1290
  }
1389
1291
 
1292
+ fn refresh_update_notice(&mut self) {
1293
+ self.update_check = None;
1294
+ self.update_notice = None;
1295
+ self.start_update_check();
1296
+ self.show_update_notice();
1297
+ }
1298
+
1390
1299
  fn show_update_notice(&mut self) {
1391
1300
  let lang = self.state.settings.ui_language.clone();
1392
1301
  if let Some(version) = &self.update_notice {
@@ -1414,7 +1323,7 @@ impl PracticodeApp {
1414
1323
  fn show_notes(&mut self) -> Result<()> {
1415
1324
  let notes = read_problem_notes(&self.root)?;
1416
1325
  if notes.is_empty() {
1417
- self.write_text_output("No notes yet. Add one with /note <text>.");
1326
+ self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences.");
1418
1327
  } else {
1419
1328
  self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
1420
1329
  }
@@ -1644,7 +1553,7 @@ impl PracticodeApp {
1644
1553
  fn open_problem(&mut self, query: &str) -> Result<()> {
1645
1554
  self.list_cursor = None;
1646
1555
  let Some(problem) = self.find_problem(query).cloned() else {
1647
- self.write_text_output(&format!("Problem not found: {query}\nTry /list."));
1556
+ self.write_text_output(&format!("Problem not found: {query}\nTry /problems."));
1648
1557
  return Ok(());
1649
1558
  };
1650
1559
  self.problem = problem;
@@ -1745,15 +1654,11 @@ impl PracticodeApp {
1745
1654
  }
1746
1655
 
1747
1656
  fn next_source_help(&self) -> String {
1748
- if self.state.settings.next_source == "ai" {
1749
- "Next behavior: plain /next asks AI every time. Use /source local to use local problems first.".to_string()
1750
- } else {
1751
- "Next behavior: /next uses local problems first and asks AI when it needs a new one. Use /next <request> to ask AI directly.".to_string()
1752
- }
1657
+ "Next behavior: /next opens unsolved local problems first and asks AI only when none remain. Use /generate <request> to create a problem immediately.".to_string()
1753
1658
  }
1754
1659
 
1755
1660
  fn busy_dots(&self) -> String {
1756
- ".".repeat(self.busy_frame / 4)
1661
+ ".".repeat((self.busy_frame / 8) % 4)
1757
1662
  }
1758
1663
 
1759
1664
  fn mode_hint(&self) -> &'static str {
@@ -1787,6 +1692,14 @@ impl PracticodeApp {
1787
1692
  }
1788
1693
  }
1789
1694
 
1695
+ fn list_or_none(values: &[String]) -> String {
1696
+ if values.is_empty() {
1697
+ "(none)".to_string()
1698
+ } else {
1699
+ values.join(", ")
1700
+ }
1701
+ }
1702
+
1790
1703
  #[derive(Clone, Debug)]
1791
1704
  pub struct TextEditor {
1792
1705
  lines: Vec<String>,