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.
@@ -0,0 +1,395 @@
1
+ use super::*;
2
+
3
+ impl PracticodeApp {
4
+ pub(super) fn draw(&mut self, frame: &mut Frame) {
5
+ let size = frame.area();
6
+ let vertical = Layout::default()
7
+ .direction(Direction::Vertical)
8
+ .constraints([
9
+ Constraint::Min(1),
10
+ Constraint::Length(1),
11
+ Constraint::Length(3),
12
+ ])
13
+ .split(size);
14
+ let body = Layout::default()
15
+ .direction(Direction::Horizontal)
16
+ .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
17
+ .split(vertical[0]);
18
+ self.code_area = if self.show_output {
19
+ Rect::default()
20
+ } else {
21
+ body[1]
22
+ };
23
+ self.output_area = if self.show_output {
24
+ vertical[0]
25
+ } else {
26
+ self.code_area
27
+ };
28
+ self.command_area = vertical[2];
29
+
30
+ let light = self.state.settings.theme == "light";
31
+ if !self.show_output {
32
+ let problem = Paragraph::new(problem_view::render(
33
+ &self.problem,
34
+ &self.state.settings.ui_language,
35
+ light,
36
+ ))
37
+ .style(Self::pane_style(light))
38
+ .block(Self::block(
39
+ ui_text(&self.state.settings.ui_language, "problem"),
40
+ light,
41
+ false,
42
+ ))
43
+ .wrap(Wrap { trim: false });
44
+ frame.render_widget(problem, body[0]);
45
+ }
46
+
47
+ if self.show_output {
48
+ let text = if self.editing_notes {
49
+ Text::from(
50
+ self.note_editor
51
+ .visible_text(self.output_area.height.saturating_sub(2) as usize),
52
+ )
53
+ } else {
54
+ self.output_text()
55
+ };
56
+ let output = Paragraph::new(text)
57
+ .style(Self::pane_style(light))
58
+ .block(Self::block(
59
+ if self.editing_notes {
60
+ PROBLEM_NOTES_PATH
61
+ } else {
62
+ ui_text(&self.state.settings.ui_language, "output")
63
+ },
64
+ light,
65
+ self.focus != Focus::Command,
66
+ ))
67
+ .wrap(Wrap { trim: false });
68
+ frame.render_widget(output, self.output_area);
69
+ } else {
70
+ let code = self
71
+ .editor
72
+ .visible_text(body[1].height.saturating_sub(2) as usize);
73
+ let title = format!("solution.{}", ext_for(&self.state.settings.language));
74
+ let code = Paragraph::new(code)
75
+ .style(Self::pane_style(light))
76
+ .block(Self::block(&title, light, self.focus == Focus::Code));
77
+ frame.render_widget(code, body[1]);
78
+ }
79
+
80
+ let status = Paragraph::new(self.status_text()).style(if light {
81
+ Style::default()
82
+ .fg(Color::Blue)
83
+ .bg(Color::Rgb(219, 234, 254))
84
+ .add_modifier(Modifier::BOLD)
85
+ } else {
86
+ Style::default()
87
+ .fg(Color::Rgb(200, 211, 245))
88
+ .bg(Color::Rgb(21, 32, 51))
89
+ .add_modifier(Modifier::BOLD)
90
+ });
91
+ frame.render_widget(status, vertical[1]);
92
+
93
+ let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
94
+ self.command.clone()
95
+ } else {
96
+ ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
97
+ };
98
+ let command = Paragraph::new(command_text)
99
+ .style(Self::pane_style(light))
100
+ .block(Self::block(
101
+ ui_text(&self.state.settings.ui_language, "command"),
102
+ light,
103
+ self.focus == Focus::Command,
104
+ ))
105
+ .wrap(Wrap { trim: false });
106
+ frame.render_widget(command, vertical[2]);
107
+ self.draw_command_palette(frame, vertical[2]);
108
+ self.set_terminal_cursor(frame, self.code_area, vertical[2]);
109
+ }
110
+
111
+ pub(super) fn wants_mouse_capture(&self) -> bool {
112
+ !self.show_output
113
+ }
114
+
115
+ pub(super) fn sync_mouse_capture(&mut self) {
116
+ let want = self.wants_mouse_capture();
117
+ if want == self.mouse_capture {
118
+ return;
119
+ }
120
+ let result = if want {
121
+ execute!(stdout(), EnableMouseCapture)
122
+ } else {
123
+ execute!(stdout(), DisableMouseCapture)
124
+ };
125
+ if result.is_ok() {
126
+ self.mouse_capture = want;
127
+ }
128
+ }
129
+
130
+ pub(super) fn disable_mouse_capture(&mut self) {
131
+ if self.mouse_capture {
132
+ let _ = execute!(stdout(), DisableMouseCapture);
133
+ self.mouse_capture = false;
134
+ }
135
+ }
136
+
137
+ pub(super) fn output_text(&self) -> Text<'static> {
138
+ let light = self.state.settings.theme == "light";
139
+ let title_style = if light {
140
+ Style::default()
141
+ .fg(Color::Blue)
142
+ .add_modifier(Modifier::BOLD)
143
+ } else {
144
+ Style::default()
145
+ .fg(Color::Yellow)
146
+ .add_modifier(Modifier::BOLD)
147
+ };
148
+ let label_style = if light {
149
+ Style::default()
150
+ .fg(Color::Magenta)
151
+ .add_modifier(Modifier::BOLD)
152
+ } else {
153
+ Style::default()
154
+ .fg(Color::Cyan)
155
+ .add_modifier(Modifier::BOLD)
156
+ };
157
+ let body_style = if light {
158
+ Style::default().fg(Color::Black)
159
+ } else {
160
+ Style::default().fg(Color::Rgb(229, 231, 235))
161
+ };
162
+ let code_style = if light {
163
+ Style::default()
164
+ .fg(Color::Black)
165
+ .bg(Color::Rgb(229, 231, 235))
166
+ } else {
167
+ Style::default()
168
+ .fg(Color::Rgb(243, 244, 246))
169
+ .bg(Color::Rgb(31, 41, 55))
170
+ };
171
+ if !self.busy_label.is_empty() {
172
+ let elapsed = self
173
+ .busy_started
174
+ .map(|started| started.elapsed().as_secs())
175
+ .unwrap_or_default();
176
+ let mut lines = vec![Line::from(Span::styled(
177
+ format!("{}{} {}s", self.busy_body, self.busy_dots(), elapsed),
178
+ title_style,
179
+ ))];
180
+ if self.busy_label == "next" {
181
+ lines.extend([
182
+ Line::default(),
183
+ Line::from(Span::styled(self.busy_game_track(), code_style)),
184
+ Line::from(Span::styled(
185
+ ui_text(&self.state.settings.ui_language, "busy_warmup").to_string(),
186
+ body_style,
187
+ )),
188
+ Line::from(Span::styled(
189
+ format!(
190
+ "{}: {} {}: {}",
191
+ ui_text(&self.state.settings.ui_language, "hits"),
192
+ self.busy_hits,
193
+ ui_text(&self.state.settings.ui_language, "misses"),
194
+ self.busy_misses
195
+ ),
196
+ label_style,
197
+ )),
198
+ Line::from(Span::styled(
199
+ ui_text(&self.state.settings.ui_language, "busy_commands_paused")
200
+ .to_string(),
201
+ body_style,
202
+ )),
203
+ ]);
204
+ }
205
+ return Text::from(lines);
206
+ }
207
+ let output = if self.output_is_markdown {
208
+ render_markdown_plain(&self.output)
209
+ } else {
210
+ self.output.clone()
211
+ };
212
+ let mut lines = Vec::new();
213
+ for line in output.lines() {
214
+ if line.is_empty() {
215
+ lines.push(Line::default());
216
+ } else if line.starts_with("PASS ")
217
+ || line.starts_with("FAIL ")
218
+ || line.starts_with("Case ")
219
+ || line.starts_with("Next:")
220
+ || line.starts_with("Fix:")
221
+ {
222
+ lines.push(Line::from(Span::styled(line.to_string(), title_style)));
223
+ } else if matches!(
224
+ line,
225
+ "Input" | "Expected" | "Got" | "Stdout" | "Stderr" | "Compile" | "Error"
226
+ ) {
227
+ lines.push(Line::from(Span::styled(line.to_string(), label_style)));
228
+ } else if line.starts_with(" ") {
229
+ lines.push(Line::from(vec![
230
+ Span::raw(" "),
231
+ Span::styled(line.trim_start().to_string(), code_style),
232
+ ]));
233
+ } else {
234
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
235
+ }
236
+ }
237
+ Text::from(lines)
238
+ }
239
+
240
+ pub(super) fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
241
+ let suggestions = self.command_suggestions();
242
+ if suggestions.is_empty() || command_area.y < 3 {
243
+ return;
244
+ }
245
+ let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
246
+ let area = Rect::new(
247
+ command_area.x,
248
+ command_area.y - height,
249
+ command_area.width,
250
+ height,
251
+ );
252
+ let selected = self.command_palette_cursor.min(suggestions.len() - 1);
253
+ let visible = height.saturating_sub(2) as usize;
254
+ let start = selected.saturating_sub(visible.saturating_sub(1));
255
+ let mut lines = suggestions
256
+ .iter()
257
+ .enumerate()
258
+ .skip(start)
259
+ .take(visible)
260
+ .map(|(index, hint)| {
261
+ let marker = if index == selected { ">" } else { " " };
262
+ format!(
263
+ "{marker} {:<16} {}",
264
+ hint.display,
265
+ ui_text(&self.state.settings.ui_language, hint.desc_key)
266
+ )
267
+ })
268
+ .collect::<Vec<_>>();
269
+ lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
270
+ frame.render_widget(Clear, area);
271
+ let light = self.state.settings.theme == "light";
272
+ frame.render_widget(
273
+ Paragraph::new(lines.join("\n"))
274
+ .style(Self::pane_style(light))
275
+ .block(Self::block(
276
+ ui_text(&self.state.settings.ui_language, "commands"),
277
+ light,
278
+ true,
279
+ )),
280
+ area,
281
+ );
282
+ }
283
+
284
+ pub(super) fn block(title: &str, light: bool, active: bool) -> Block<'static> {
285
+ let border = if active {
286
+ if light {
287
+ Style::default()
288
+ .fg(Color::Magenta)
289
+ .add_modifier(Modifier::BOLD)
290
+ } else {
291
+ Style::default()
292
+ .fg(Color::Yellow)
293
+ .add_modifier(Modifier::BOLD)
294
+ }
295
+ } else if light {
296
+ Style::default().fg(Color::Blue)
297
+ } else {
298
+ Style::default().fg(Color::Cyan)
299
+ };
300
+ let border = border.bg(Self::pane_bg(light));
301
+ Block::default()
302
+ .borders(Borders::ALL)
303
+ .title(Self::pane_title(title, active))
304
+ .style(Self::pane_style(light))
305
+ .border_style(border)
306
+ }
307
+
308
+ pub(super) fn pane_style(light: bool) -> Style {
309
+ if light {
310
+ Style::default()
311
+ .fg(Color::Rgb(17, 24, 39))
312
+ .bg(Self::pane_bg(light))
313
+ } else {
314
+ Style::default()
315
+ .fg(Color::Rgb(229, 231, 235))
316
+ .bg(Self::pane_bg(light))
317
+ }
318
+ }
319
+
320
+ pub(super) fn pane_bg(light: bool) -> Color {
321
+ if light {
322
+ Color::Rgb(248, 250, 252)
323
+ } else {
324
+ Color::Rgb(17, 24, 39)
325
+ }
326
+ }
327
+
328
+ pub fn pane_style_for_test(light: bool) -> Style {
329
+ Self::pane_style(light)
330
+ }
331
+
332
+ pub(super) fn pane_title(title: &str, active: bool) -> String {
333
+ if active {
334
+ format!("> {title}")
335
+ } else {
336
+ title.to_string()
337
+ }
338
+ }
339
+ pub(super) fn set_terminal_cursor(
340
+ &self,
341
+ frame: &mut Frame,
342
+ code_area: Rect,
343
+ command_area: Rect,
344
+ ) {
345
+ match self.focus {
346
+ Focus::Command => {
347
+ let before = prefix(&self.command, self.command_cursor);
348
+ let x = command_area
349
+ .x
350
+ .saturating_add(1)
351
+ .saturating_add(display_width(&before) as u16)
352
+ .min(command_area.right().saturating_sub(2));
353
+ frame.set_cursor_position(Position::new(x, command_area.y.saturating_add(1)));
354
+ }
355
+ Focus::Code if !self.show_output => {
356
+ if let Some(position) = self.editor.cursor_position(code_area) {
357
+ frame.set_cursor_position(position);
358
+ }
359
+ }
360
+ Focus::Output if self.editing_notes => {
361
+ if let Some(position) = self.note_editor.cursor_position(self.output_area) {
362
+ frame.set_cursor_position(position);
363
+ }
364
+ }
365
+ _ => {}
366
+ }
367
+ }
368
+ }
369
+
370
+ #[cfg(test)]
371
+ mod tests {
372
+ use super::*;
373
+ use ratatui::{Terminal, backend::TestBackend};
374
+
375
+ fn tmp_root(name: &str) -> PathBuf {
376
+ let root =
377
+ std::env::temp_dir().join(format!("practicode-view-{name}-{}", std::process::id()));
378
+ let _ = fs::remove_dir_all(&root);
379
+ fs::create_dir_all(&root).unwrap();
380
+ root
381
+ }
382
+
383
+ #[test]
384
+ fn output_uses_full_body_so_terminal_drag_selection_has_no_side_pane() {
385
+ let mut app = PracticodeApp::new(tmp_root("full-output")).unwrap();
386
+ app.handle_command("help").unwrap();
387
+
388
+ let backend = TestBackend::new(80, 24);
389
+ let mut terminal = Terminal::new(backend).unwrap();
390
+ terminal.draw(|frame| app.draw(frame)).unwrap();
391
+
392
+ assert_eq!(app.output_area, Rect::new(0, 0, 80, 20));
393
+ assert_eq!(app.code_area, Rect::default());
394
+ }
395
+ }