practicode 0.1.7 → 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/src/core.rs CHANGED
@@ -36,6 +36,10 @@ pub struct Settings {
36
36
  pub topics: Vec<String>,
37
37
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
38
38
  pub avoid_topics: Vec<String>,
39
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
40
+ pub generate_languages: Vec<String>,
41
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
42
+ pub generate_ui_languages: Vec<String>,
39
43
  #[serde(default = "default_editor")]
40
44
  pub editor: String,
41
45
  #[serde(default = "default_next_source")]
@@ -57,6 +61,8 @@ impl Default for Settings {
57
61
  difficulty: default_difficulty(),
58
62
  topics: Vec::new(),
59
63
  avoid_topics: Vec::new(),
64
+ generate_languages: Vec::new(),
65
+ generate_ui_languages: Vec::new(),
60
66
  editor: default_editor(),
61
67
  next_source: default_next_source(),
62
68
  ai_provider: default_ai_provider(),
@@ -291,12 +297,19 @@ fn validate_bank(bank: &[Problem], path: &Path) -> Result<()> {
291
297
  problem.id
292
298
  );
293
299
  }
294
- for language in LANGUAGES {
295
- if !problem.answers.contains_key(*language) {
300
+ if problem.answers.is_empty() {
301
+ bail!(
302
+ "{} problem {} must contain at least one answer",
303
+ path.display(),
304
+ problem.id
305
+ );
306
+ }
307
+ for language in problem.answers.keys() {
308
+ if !LANGUAGES.contains(&language.as_str()) {
296
309
  bail!(
297
- "{} problem {} missing {language} answer",
310
+ "{} problem {} has unsupported answer language {language}",
298
311
  path.display(),
299
- problem.id
312
+ problem.id,
300
313
  );
301
314
  }
302
315
  }
@@ -381,6 +394,8 @@ pub fn normalize_settings(settings: &mut Settings) {
381
394
  settings.difficulty = normalize_difficulty(&settings.difficulty);
382
395
  settings.topics = normalize_topic_list(&settings.topics);
383
396
  settings.avoid_topics = normalize_topic_list(&settings.avoid_topics);
397
+ settings.generate_languages = normalize_language_list(&settings.generate_languages);
398
+ settings.generate_ui_languages = normalize_ui_language_list(&settings.generate_ui_languages);
384
399
  settings.next_source = normalize_next_source(&settings.next_source);
385
400
  settings.ai_provider = normalize_ai_provider(&settings.ai_provider);
386
401
  if settings.ai_model.trim().is_empty() {
@@ -401,6 +416,50 @@ pub fn normalize_language(language: &str) -> String {
401
416
  }
402
417
  }
403
418
 
419
+ pub fn parse_language_list(value: &str) -> Vec<String> {
420
+ let mut languages = Vec::new();
421
+ for language in value.split(',') {
422
+ let language = language.trim().to_lowercase();
423
+ if language == "all" {
424
+ return Vec::new();
425
+ }
426
+ if LANGUAGES.contains(&language.as_str()) && !languages.contains(&language) {
427
+ languages.push(language);
428
+ }
429
+ }
430
+ languages
431
+ }
432
+
433
+ pub fn normalize_language_list(languages: &[String]) -> Vec<String> {
434
+ parse_language_list(&languages.join(","))
435
+ }
436
+
437
+ pub fn parse_ui_language_list(value: &str) -> Vec<String> {
438
+ let mut languages = Vec::new();
439
+ for language in value.split(',') {
440
+ let lower = language.trim().to_lowercase();
441
+ if lower == "all" {
442
+ return Vec::new();
443
+ }
444
+ let language = lower
445
+ .split(['-', '_'])
446
+ .next()
447
+ .filter(|value| UI_LANGUAGES.contains(value))
448
+ .unwrap_or("");
449
+ if language == "all" {
450
+ return Vec::new();
451
+ }
452
+ if !language.is_empty() && !languages.iter().any(|value| value == language) {
453
+ languages.push(language.to_string());
454
+ }
455
+ }
456
+ languages
457
+ }
458
+
459
+ pub fn normalize_ui_language_list(languages: &[String]) -> Vec<String> {
460
+ parse_ui_language_list(&languages.join(","))
461
+ }
462
+
404
463
  pub fn normalize_next_source(source: &str) -> String {
405
464
  if source == "ai" {
406
465
  "ai".to_string()
package/src/lib.rs CHANGED
@@ -1,9 +1,5 @@
1
1
  use anyhow::{Context, Result};
2
- use crossterm::{
3
- cursor::SetCursorStyle,
4
- event::{DisableMouseCapture, EnableMouseCapture},
5
- execute,
6
- };
2
+ use crossterm::{cursor::SetCursorStyle, event::DisableMouseCapture, execute};
7
3
  use std::{env, io::stdout};
8
4
 
9
5
  pub mod ai;
@@ -59,7 +55,7 @@ pub fn run_cli() -> Result<()> {
59
55
 
60
56
  let mut app = tui::PracticodeApp::new(root)?;
61
57
  let mut terminal = ratatui::init();
62
- let _ = execute!(stdout(), SetCursorStyle::SteadyBar, EnableMouseCapture);
58
+ let _ = execute!(stdout(), SetCursorStyle::SteadyBar);
63
59
  let result = app.run(&mut terminal);
64
60
  ratatui::restore();
65
61
  let _ = execute!(
@@ -120,6 +120,20 @@ pub(super) const COMMAND_HINTS: &[CommandHint] = &[
120
120
  keep_open: true,
121
121
  help: true,
122
122
  },
123
+ CommandHint {
124
+ insert: "generate-languages ",
125
+ display: "/generate-languages <list|all>",
126
+ desc_key: "cmd_generate_languages",
127
+ keep_open: true,
128
+ help: true,
129
+ },
130
+ CommandHint {
131
+ insert: "generate-ui ",
132
+ display: "/generate-ui <list|all>",
133
+ desc_key: "cmd_generate_ui",
134
+ keep_open: true,
135
+ help: true,
136
+ },
123
137
  CommandHint {
124
138
  insert: "provider codex",
125
139
  display: "/provider codex",
@@ -0,0 +1,180 @@
1
+ use crate::text::{byte_index, char_len, compose_hangul_jamo, display_width, prefix};
2
+ use ratatui::layout::{Position, Rect};
3
+
4
+ #[derive(Clone, Debug)]
5
+ pub struct TextEditor {
6
+ lines: Vec<String>,
7
+ row: usize,
8
+ col: usize,
9
+ scroll: usize,
10
+ }
11
+
12
+ impl Default for TextEditor {
13
+ fn default() -> Self {
14
+ Self {
15
+ lines: vec![String::new()],
16
+ row: 0,
17
+ col: 0,
18
+ scroll: 0,
19
+ }
20
+ }
21
+ }
22
+
23
+ impl TextEditor {
24
+ pub fn set_text(&mut self, text: &str) {
25
+ self.lines = text.split('\n').map(str::to_string).collect();
26
+ if text.ends_with('\n') {
27
+ self.lines.pop();
28
+ self.lines.push(String::new());
29
+ }
30
+ if self.lines.is_empty() {
31
+ self.lines.push(String::new());
32
+ }
33
+ self.row = 0;
34
+ self.col = 0;
35
+ self.scroll = 0;
36
+ }
37
+
38
+ pub fn text(&self) -> String {
39
+ self.lines.join("\n")
40
+ }
41
+
42
+ pub(super) fn visible_text(&mut self, height: usize) -> String {
43
+ if self.row < self.scroll {
44
+ self.scroll = self.row;
45
+ } else if height > 0 && self.row >= self.scroll + height {
46
+ self.scroll = self.row + 1 - height;
47
+ }
48
+ let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
49
+ self.lines
50
+ .iter()
51
+ .enumerate()
52
+ .skip(self.scroll)
53
+ .take(height.max(1))
54
+ .map(|(index, line)| {
55
+ let cursor = if index == self.row { ">" } else { " " };
56
+ format!("{cursor}{:>width$} {line}", index + 1, width = line_width)
57
+ })
58
+ .collect::<Vec<_>>()
59
+ .join("\n")
60
+ }
61
+
62
+ pub(super) fn cursor_position(&self, area: Rect) -> Option<Position> {
63
+ if self.row < self.scroll {
64
+ return None;
65
+ }
66
+ let visible_row = self.row - self.scroll;
67
+ let inner_height = area.height.saturating_sub(2) as usize;
68
+ if visible_row >= inner_height {
69
+ return None;
70
+ }
71
+ let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
72
+ let prefix_width = 1 + line_width + 1;
73
+ let line = self.lines.get(self.row)?;
74
+ let text_before_cursor = prefix(line, self.col);
75
+ let x = area
76
+ .x
77
+ .saturating_add(1)
78
+ .saturating_add((prefix_width + display_width(&text_before_cursor)) as u16)
79
+ .min(area.right().saturating_sub(2));
80
+ let y = area.y.saturating_add(1).saturating_add(visible_row as u16);
81
+ Some(Position::new(x, y))
82
+ }
83
+
84
+ pub fn insert_char(&mut self, char: char) {
85
+ self.ensure_cursor();
86
+ let byte = byte_index(&self.lines[self.row], self.col);
87
+ self.lines[self.row].insert(byte, char);
88
+ self.col += 1;
89
+ self.normalize_current_line();
90
+ }
91
+
92
+ pub fn insert_newline(&mut self) {
93
+ self.ensure_cursor();
94
+ let byte = byte_index(&self.lines[self.row], self.col);
95
+ let rest = self.lines[self.row].split_off(byte);
96
+ self.lines.insert(self.row + 1, rest);
97
+ self.row += 1;
98
+ self.col = 0;
99
+ }
100
+
101
+ pub fn backspace(&mut self) {
102
+ self.ensure_cursor();
103
+ if self.col > 0 {
104
+ let start = byte_index(&self.lines[self.row], self.col - 1);
105
+ let end = byte_index(&self.lines[self.row], self.col);
106
+ self.lines[self.row].replace_range(start..end, "");
107
+ self.col -= 1;
108
+ self.normalize_current_line();
109
+ } else if self.row > 0 {
110
+ let current = self.lines.remove(self.row);
111
+ self.row -= 1;
112
+ self.col = char_len(&self.lines[self.row]);
113
+ self.lines[self.row].push_str(&current);
114
+ }
115
+ }
116
+
117
+ pub(super) fn delete(&mut self) {
118
+ self.ensure_cursor();
119
+ if self.col < char_len(&self.lines[self.row]) {
120
+ let start = byte_index(&self.lines[self.row], self.col);
121
+ let end = byte_index(&self.lines[self.row], self.col + 1);
122
+ self.lines[self.row].replace_range(start..end, "");
123
+ self.normalize_current_line();
124
+ } else if self.row + 1 < self.lines.len() {
125
+ let next = self.lines.remove(self.row + 1);
126
+ self.lines[self.row].push_str(&next);
127
+ }
128
+ }
129
+
130
+ pub(super) fn move_left(&mut self) {
131
+ if self.col > 0 {
132
+ self.col -= 1;
133
+ } else if self.row > 0 {
134
+ self.row -= 1;
135
+ self.col = char_len(&self.lines[self.row]);
136
+ }
137
+ }
138
+
139
+ pub(super) fn move_right(&mut self) {
140
+ if self.col < char_len(&self.lines[self.row]) {
141
+ self.col += 1;
142
+ } else if self.row + 1 < self.lines.len() {
143
+ self.row += 1;
144
+ self.col = 0;
145
+ }
146
+ }
147
+
148
+ pub(super) fn move_up(&mut self) {
149
+ if self.row > 0 {
150
+ self.row -= 1;
151
+ self.col = self.col.min(char_len(&self.lines[self.row]));
152
+ }
153
+ }
154
+
155
+ pub(super) fn move_down(&mut self) {
156
+ if self.row + 1 < self.lines.len() {
157
+ self.row += 1;
158
+ self.col = self.col.min(char_len(&self.lines[self.row]));
159
+ }
160
+ }
161
+
162
+ fn ensure_cursor(&mut self) {
163
+ if self.lines.is_empty() {
164
+ self.lines.push(String::new());
165
+ }
166
+ self.row = self.row.min(self.lines.len() - 1);
167
+ self.col = self.col.min(char_len(&self.lines[self.row]));
168
+ }
169
+
170
+ fn normalize_current_line(&mut self) {
171
+ let normalized = compose_hangul_jamo(&self.lines[self.row]);
172
+ if normalized == self.lines[self.row] {
173
+ self.col = self.col.min(char_len(&self.lines[self.row]));
174
+ return;
175
+ }
176
+ let old_prefix = prefix(&self.lines[self.row], self.col);
177
+ self.lines[self.row] = normalized;
178
+ self.col = char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.lines[self.row]));
179
+ }
180
+ }
@@ -0,0 +1,138 @@
1
+ use crate::core::{Problem, localized, normalize_ui_language, ui_text};
2
+ use ratatui::{
3
+ style::{Color, Modifier, Style},
4
+ text::{Line, Span, Text},
5
+ };
6
+
7
+ pub(super) fn render(problem: &Problem, ui_language: &str, light: bool) -> Text<'static> {
8
+ let lang = normalize_ui_language(ui_language);
9
+ let title_style = if light {
10
+ Style::default()
11
+ .fg(Color::Blue)
12
+ .add_modifier(Modifier::BOLD)
13
+ } else {
14
+ Style::default()
15
+ .fg(Color::Yellow)
16
+ .add_modifier(Modifier::BOLD)
17
+ };
18
+ let section_style = if light {
19
+ Style::default()
20
+ .fg(Color::Magenta)
21
+ .add_modifier(Modifier::BOLD)
22
+ } else {
23
+ Style::default()
24
+ .fg(Color::Cyan)
25
+ .add_modifier(Modifier::BOLD)
26
+ };
27
+ let body_style = if light {
28
+ Style::default().fg(Color::Black)
29
+ } else {
30
+ Style::default().fg(Color::Rgb(229, 231, 235))
31
+ };
32
+ let meta_style = if light {
33
+ Style::default().fg(Color::Rgb(75, 85, 99))
34
+ } else {
35
+ Style::default().fg(Color::Rgb(156, 163, 175))
36
+ };
37
+ let code_style = if light {
38
+ Style::default()
39
+ .fg(Color::Black)
40
+ .bg(Color::Rgb(229, 231, 235))
41
+ } else {
42
+ Style::default()
43
+ .fg(Color::Rgb(243, 244, 246))
44
+ .bg(Color::Rgb(31, 41, 55))
45
+ };
46
+ let number = problem
47
+ .id
48
+ .split_once('-')
49
+ .map(|(number, _)| number)
50
+ .unwrap_or(&problem.id);
51
+ let mut lines = vec![
52
+ Line::from(Span::styled(
53
+ format!("{number}. {}", localized(&problem.title, &lang)),
54
+ title_style,
55
+ )),
56
+ Line::from(Span::styled(
57
+ format!(
58
+ "{}: {} {}: {}",
59
+ ui_text(&lang, "difficulty"),
60
+ problem.difficulty,
61
+ ui_text(&lang, "topics"),
62
+ problem.topics.join(", ")
63
+ ),
64
+ meta_style,
65
+ )),
66
+ ];
67
+ lines.push(Line::default());
68
+ for line in localized(&problem.statement, &lang).trim_end().lines() {
69
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
70
+ }
71
+ push_problem_section(
72
+ &mut lines,
73
+ ui_text(&lang, "input"),
74
+ &localized(&problem.input, &lang),
75
+ section_style,
76
+ body_style,
77
+ );
78
+ push_problem_section(
79
+ &mut lines,
80
+ ui_text(&lang, "output"),
81
+ &localized(&problem.output, &lang),
82
+ section_style,
83
+ body_style,
84
+ );
85
+ lines.push(Line::default());
86
+ lines.push(Line::from(Span::styled(
87
+ ui_text(&lang, "examples").to_string(),
88
+ section_style,
89
+ )));
90
+ for (index, case) in problem.examples.iter().enumerate() {
91
+ lines.push(Line::from(Span::styled(
92
+ format!(" {} {}", ui_text(&lang, "example"), index + 1),
93
+ meta_style.add_modifier(Modifier::BOLD),
94
+ )));
95
+ lines.push(Line::from(Span::styled(
96
+ format!(" {}", ui_text(&lang, "input")),
97
+ meta_style,
98
+ )));
99
+ push_code_lines(&mut lines, &case.input, code_style);
100
+ lines.push(Line::from(Span::styled(
101
+ format!(" {}", ui_text(&lang, "output")),
102
+ meta_style,
103
+ )));
104
+ push_code_lines(&mut lines, &case.output, code_style);
105
+ }
106
+ Text::from(lines)
107
+ }
108
+
109
+ fn push_problem_section(
110
+ lines: &mut Vec<Line<'static>>,
111
+ title: &str,
112
+ body: &str,
113
+ section_style: Style,
114
+ body_style: Style,
115
+ ) {
116
+ lines.push(Line::default());
117
+ lines.push(Line::from(Span::styled(title.to_string(), section_style)));
118
+ for line in body.trim_end().lines() {
119
+ lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
120
+ }
121
+ }
122
+
123
+ fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
124
+ let body = body.trim_end();
125
+ if body.is_empty() {
126
+ lines.push(Line::from(vec![
127
+ Span::raw(" "),
128
+ Span::styled("<empty>".to_string(), code_style),
129
+ ]));
130
+ return;
131
+ }
132
+ for line in body.lines() {
133
+ lines.push(Line::from(vec![
134
+ Span::raw(" "),
135
+ Span::styled(line.to_string(), code_style),
136
+ ]));
137
+ }
138
+ }