1use crate::model::{Current, DEFAULT_ID, Listing};
8use ratatui::Frame;
9use ratatui::layout::{Constraint, Direction, Layout, Rect};
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
13
14use crate::app::{App, Input, View, is_running, is_selected};
15use crate::logs;
16
17const SCHED_PREFIX: &str = "scx_";
18
19pub fn draw(frame: &mut Frame, app: &mut App) {
20 let [header, body, footer] = Layout::new(
21 Direction::Vertical,
22 [
23 Constraint::Length(1),
24 Constraint::Min(0),
25 Constraint::Length(3),
26 ],
27 )
28 .areas(frame.area());
29
30 draw_header(frame, app, header);
31
32 match app.view {
33 View::Recommended | View::All => {
34 let [left, right] = Layout::new(
35 Direction::Horizontal,
36 [Constraint::Percentage(45), Constraint::Percentage(55)],
37 )
38 .areas(body);
39 if app.view == View::Recommended {
40 draw_recommended_list(frame, app, left);
41 } else {
42 draw_all_list(frame, app, left);
43 }
44 draw_status_panel(frame, app, right);
45 }
46 View::Logs => draw_logs(frame, app, body),
47 }
48
49 draw_footer(frame, app, footer);
50}
51
52fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
53 let line = Line::from(vec![
54 Span::styled(
55 " scxtui ",
56 Style::default()
57 .fg(Color::Black)
58 .bg(Color::Cyan)
59 .add_modifier(Modifier::BOLD),
60 ),
61 Span::raw(" via "),
62 Span::styled(app.backend_label(), Style::default().fg(Color::Cyan)),
63 Span::raw(if app.view == View::All {
64 " all schedulers"
65 } else {
66 ""
67 }),
68 ]);
69 frame.render_widget(Paragraph::new(line), area);
70}
71
72fn marker(listing: Option<&Listing>, id: &str) -> (&'static str, Style) {
75 match listing {
76 Some(l) if is_running(l, id) => (
77 "● ",
78 Style::default()
79 .fg(Color::Green)
80 .add_modifier(Modifier::BOLD),
81 ),
82 Some(l) if is_selected(l, id) => ("○ ", Style::default().fg(Color::Yellow)),
83 _ => (" ", Style::default()),
84 }
85}
86
87fn draw_recommended_list(frame: &mut Frame, app: &App, area: Rect) {
88 let items: Vec<ListItem> = app
89 .recommended()
90 .iter()
91 .map(|pick| {
92 let (mark, style) = marker(app.listing.as_ref(), &pick.id);
93 let style = if !pick.available {
94 Style::default().fg(Color::DarkGray)
95 } else {
96 style
97 };
98 let mut lines = vec![Line::from(vec![
99 Span::styled(mark, style),
100 Span::styled(pick.label.clone(), style),
101 Span::styled(
102 if pick.available {
103 String::new()
104 } else {
105 " (not installed)".to_owned()
106 },
107 Style::default().fg(Color::DarkGray),
108 ),
109 ])];
110 if !pick.description.is_empty() {
111 lines.push(Line::from(Span::styled(
112 format!(" {}", pick.description),
113 Style::default().fg(Color::Gray),
114 )));
115 }
116 ListItem::new(lines)
117 })
118 .collect();
119
120 let title = match &app.listing {
121 Some(l) if l.customized => " Recommended (administrator's catalog) ",
122 _ => " Recommended ",
123 };
124 let list = List::new(items)
125 .block(Block::default().borders(Borders::ALL).title(title))
126 .highlight_style(
127 Style::default()
128 .bg(Color::DarkGray)
129 .add_modifier(Modifier::BOLD),
130 )
131 .highlight_symbol("▶ ");
132
133 let mut state = ListState::default().with_selected(Some(app.selected));
134 frame.render_stateful_widget(list, area, &mut state);
135}
136
137fn draw_all_list(frame: &mut Frame, app: &App, area: Rect) {
138 let items: Vec<ListItem> = app
139 .all()
140 .iter()
141 .map(|sched| {
142 let (mark, style) = marker(app.listing.as_ref(), &sched.name);
143 ListItem::new(Line::from(vec![
144 Span::styled(mark, style),
145 Span::styled(strip_prefix(&sched.name).to_owned(), style),
146 Span::styled(
147 if sched.recommended {
148 " recommended"
149 } else {
150 ""
151 },
152 Style::default().fg(Color::DarkGray),
153 ),
154 ]))
155 })
156 .collect();
157
158 let list = List::new(items)
159 .block(
160 Block::default()
161 .borders(Borders::ALL)
162 .title(" All installed schedulers "),
163 )
164 .highlight_style(
165 Style::default()
166 .bg(Color::DarkGray)
167 .add_modifier(Modifier::BOLD),
168 )
169 .highlight_symbol("▶ ");
170
171 let mut state = ListState::default().with_selected(Some(app.sched_selected));
172 frame.render_stateful_widget(list, area, &mut state);
173}
174
175fn state_line(current: &Current) -> (String, Color) {
176 match current.scheduler.as_deref() {
177 Some(DEFAULT_ID) => match ¤t.next {
178 Some(next) => (
179 format!(
180 "default (the kernel's scheduler); {} selected but not running",
181 strip_prefix(next.name())
182 ),
183 if current.failure.is_some() {
184 Color::Red
185 } else {
186 Color::Yellow
187 },
188 ),
189 None => ("default: the kernel's scheduler".to_owned(), Color::White),
190 },
191 Some(name) => (format!("{} running", strip_prefix(name)), Color::Green),
192 None if current.attached() => (
193 "a scheduler is attached outside scx.service".to_owned(),
194 Color::Yellow,
195 ),
196 None => (
197 "scx.service is active, nothing attached yet".to_owned(),
198 Color::Yellow,
199 ),
200 }
201}
202
203fn draw_status_panel(frame: &mut Frame, app: &App, area: Rect) {
204 let mut lines: Vec<Line> = Vec::new();
205
206 match &app.listing {
207 Some(listing) => {
208 let current = &listing.current;
209 let (state, color) = state_line(current);
210 lines.push(kv("State", &state, color));
211 match ¤t.running {
212 Some(r) if r.args.is_empty() => {
213 lines.push(kv("Running", strip_prefix(&r.sched), Color::White));
214 }
215 Some(r) => lines.push(kv(
216 "Running",
217 &format!("{} {}", strip_prefix(&r.sched), format_flags(&r.args)),
218 Color::White,
219 )),
220 None if current.unit.active => lines.push(kv(
221 "Running",
222 "scx.service active, process not readable",
223 Color::Yellow,
224 )),
225 None => lines.push(kv(
226 "Running",
227 "nothing (scx.service inactive)",
228 Color::White,
229 )),
230 }
231 if let Some(err) = ¤t.config_error {
232 lines.push(kv("Next start", "unknown", Color::Red));
233 lines.push(Line::from(Span::styled(
234 format!(" ⚠ {err}"),
235 Style::default().fg(Color::Red),
236 )));
237 } else {
238 match ¤t.next {
239 Some(next) => {
240 let text = format!(
241 "{} {}{}",
242 strip_prefix(next.name()),
243 format_flags(&next.args),
244 if next.overridden {
245 " (SCX_*_OVERRIDE in effect)"
246 } else {
247 ""
248 }
249 );
250 let changed = current
251 .running
252 .as_ref()
253 .is_some_and(|r| r.sched != next.name() || r.args != next.args);
254 let color = if next.overridden || changed {
255 Color::Yellow
256 } else {
257 Color::White
258 };
259 lines.push(kv("Next start", text.trim_end(), color));
260 }
261 None => lines.push(kv("Next start", "nothing configured", Color::White)),
262 }
263 }
264 if let Some(f) = ¤t.failure {
265 lines.push(kv(
266 "Last start",
267 &format!("failed ({}): {}", f.status, f.message),
268 Color::Red,
269 ));
270 }
271 }
272 None => {
273 let reason = app
274 .listing_error
275 .as_deref()
276 .map_or_else(|| "unknown".to_owned(), |err| format!("unknown; {err}"));
277 lines.push(kv("State", &reason, Color::Red));
278 }
279 }
280
281 lines.push(Line::default());
282 draw_kernel_section(app, &mut lines);
283
284 lines.push(Line::default());
285 match app.view {
286 View::Recommended => {
287 if let Some(pick) = app.selected_pick() {
288 lines.push(Line::from(vec![
289 Span::styled("Selected: ", Style::default().add_modifier(Modifier::BOLD)),
290 Span::styled(pick.label.clone(), Style::default().fg(Color::Cyan)),
291 ]));
292 lines.push(Line::from(Span::styled(
293 format!(
294 " {}",
295 if pick.id == DEFAULT_ID {
296 "kernel default".to_owned()
297 } else {
298 pick.id.clone()
299 }
300 ),
301 Style::default().fg(Color::Gray),
302 )));
303 if let Some(path) = &pick.path {
304 lines.push(Line::from(Span::styled(
305 format!(" {path}"),
306 Style::default().fg(Color::DarkGray),
307 )));
308 }
309 }
310 if let Some(listing) = &app.listing {
311 lines.push(Line::from(Span::styled(
312 format!(" catalog from {}", listing.source.display()),
313 Style::default().fg(Color::DarkGray),
314 )));
315 }
316 }
317 View::All => {
318 if let Some(sched) = app.selected_scheduler() {
319 lines.push(Line::from(vec![
320 Span::styled("Selected: ", Style::default().add_modifier(Modifier::BOLD)),
321 Span::styled(
322 strip_prefix(&sched.name).to_owned(),
323 Style::default().fg(Color::Cyan),
324 ),
325 Span::raw(
326 " (Enter starts it with its own defaults, a with arguments you type)",
327 ),
328 ]));
329 lines.push(Line::from(Span::styled(
330 format!(" {}", sched.path),
331 Style::default().fg(Color::DarkGray),
332 )));
333 }
334 lines.push(Line::from(Span::styled(
335 " a scheduler unsuited to the workload can perform worse than the kernel's \
336default; the recommended ones are marked",
337 Style::default().fg(Color::Yellow),
338 )));
339 }
340 View::Logs => {}
341 }
342
343 if let Some(input) = &app.args {
344 lines.push(Line::default());
345 let mut spans = vec![Span::styled(
346 "Args: ",
347 Style::default().add_modifier(Modifier::BOLD),
348 )];
349 spans.extend(input_spans(input));
350 lines.push(Line::from(spans));
351 lines.push(Line::from(Span::styled(
352 " the selected scheduler's own command-line arguments, as to `scxcli run`; Enter \
353runs it with them, Esc cancels",
354 Style::default().fg(Color::DarkGray),
355 )));
356 }
357
358 let panel = Paragraph::new(lines)
359 .wrap(Wrap { trim: false })
360 .block(Block::default().borders(Borders::ALL).title(" Status "));
361 frame.render_widget(panel, area);
362}
363
364fn draw_logs(frame: &mut Frame, app: &mut App, area: Rect) {
365 let height = area.height.saturating_sub(2) as usize;
366 app.log_page = height.max(1);
367
368 let total = app.log_lines.len();
369 let max_scroll = total.saturating_sub(height);
370 if app.log_scroll > max_scroll {
371 app.log_scroll = max_scroll;
372 }
373 let end = total - app.log_scroll;
374 let start = end.saturating_sub(height);
375
376 let lines: Vec<Line> = app.log_lines[start..end]
377 .iter()
378 .map(|line| {
379 if line.continuation {
380 Line::from(Span::styled(
381 format!(" ↳ {}", line.text),
382 Style::default().fg(Color::DarkGray),
383 ))
384 } else {
385 Line::from(vec![
386 Span::styled(
387 format!("{:8} ", line.time),
388 Style::default().fg(Color::DarkGray),
389 ),
390 Span::styled(line.text.clone(), priority_style(line.priority)),
391 ])
392 }
393 })
394 .collect();
395
396 let boot = if app.log_previous_boot {
397 "-1 (previous)"
398 } else {
399 "0 (current)"
400 };
401 let title = format!(" Logs: {} · boot {} · {} lines ", logs::UNIT, boot, total);
402
403 let panel = Paragraph::new(lines).block(
404 Block::default()
405 .borders(Borders::ALL)
406 .title(title)
407 .title_style(Style::default().add_modifier(Modifier::BOLD)),
408 );
409 frame.render_widget(panel, area);
410}
411
412fn draw_kernel_section(app: &App, lines: &mut Vec<Line>) {
413 let Some(kernel) = &app.kernel else {
414 lines.push(kv(
415 "Kernel",
416 "sched_ext not available in this kernel",
417 Color::DarkGray,
418 ));
419 return;
420 };
421 let ops_suffix = kernel
422 .ops
423 .as_deref()
424 .map(|ops| format!(" · ops: {ops}"))
425 .unwrap_or_default();
426 let color = if kernel.enabled() {
427 Color::Green
428 } else {
429 Color::White
430 };
431 lines.push(kv(
432 "Kernel",
433 &format!("sched_ext {}{}", kernel.state, ops_suffix),
434 color,
435 ));
436 if let Some(text) = app.kernel_warning() {
437 lines.push(Line::from(Span::styled(
438 format!(" ⚠ {text}"),
439 Style::default().fg(Color::Yellow),
440 )));
441 }
442}
443
444fn priority_style(priority: u8) -> Style {
445 match priority {
446 0..=3 => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
447 4 => Style::default().fg(Color::Yellow),
448 7 => Style::default().fg(Color::DarkGray),
449 _ => Style::default(),
450 }
451}
452
453fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
454 let [keys_area, message_area] = Layout::new(
455 Direction::Vertical,
456 [Constraint::Length(1), Constraint::Length(2)],
457 )
458 .areas(area);
459
460 let key_help = match (app.view, &app.args) {
461 (_, Some(_)) => " type scheduler arguments · Enter run with them · Esc cancel",
462 (View::Recommended, None) => {
463 " ↑/↓ select · Enter start · s stop · A all schedulers · l logs · t monitor · R refresh · q quit"
464 }
465 (View::All, None) => {
466 " ↑/↓ select · Enter start with defaults · a run with args · s stop · l logs · t monitor · R refresh · A/Esc back"
467 }
468 (View::Logs, None) => {
469 " ↑/↓ scroll · PgUp/PgDn page · g/G top/bottom · b boot · R reload · q/Esc back"
470 }
471 };
472
473 let keys = Line::from(Span::styled(key_help, Style::default().fg(Color::DarkGray)));
474 frame.render_widget(Paragraph::new(keys), keys_area);
475
476 if let Some(message) = &app.message {
477 let color = if message.is_error {
478 Color::Red
479 } else {
480 Color::Green
481 };
482 let line = Line::from(Span::styled(
483 format!(" {}", message.text),
484 Style::default().fg(color),
485 ));
486 frame.render_widget(
487 Paragraph::new(line).wrap(Wrap { trim: false }),
488 message_area,
489 );
490 }
491}
492
493fn kv<'a>(key: &'a str, value: &str, value_color: Color) -> Line<'a> {
494 Line::from(vec![
495 Span::styled(
496 format!("{key}: "),
497 Style::default().add_modifier(Modifier::BOLD),
498 ),
499 Span::styled(value.to_owned(), Style::default().fg(value_color)),
500 ])
501}
502
503fn strip_prefix(sched: &str) -> &str {
504 sched.strip_prefix(SCHED_PREFIX).unwrap_or(sched)
505}
506
507fn input_spans(input: &Input) -> Vec<Span<'static>> {
509 let mut before = String::new();
510 let mut at = None;
511 let mut after = String::new();
512 for (i, c) in input.buffer.chars().enumerate() {
513 match i.cmp(&input.cursor) {
514 std::cmp::Ordering::Less => before.push(c),
515 std::cmp::Ordering::Equal => at = Some(c),
516 std::cmp::Ordering::Greater => after.push(c),
517 }
518 }
519 let cursor = at.map_or_else(|| " ".to_owned(), |c| c.to_string());
520 vec![
521 Span::raw(before),
522 Span::styled(cursor, Style::default().add_modifier(Modifier::REVERSED)),
523 Span::raw(after),
524 ]
525}
526
527fn format_flags(flags: &[String]) -> String {
529 shell_words::join(flags)
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn flags_format_round_trips() {
538 let flags = ["--name", "foo bar", "", "a'b"]
539 .map(str::to_string)
540 .to_vec();
541 assert_eq!(shell_words::split(&format_flags(&flags)), Ok(flags));
542 }
543}