Skip to main content

scxtui/
ui.rs

1// SPDX-License-Identifier: GPL-2.0
2
3//! Rendering. Almost a pure function of the [`App`] state; the one
4//! exception is the log view writing its viewport height and clamped
5//! scroll offset back into the app.
6
7use 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
72/// The marker in front of a list entry: running, selected for the next
73/// start but not running, or nothing.
74fn 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 &current.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 &current.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) = &current.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 &current.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) = &current.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
507/// Renders the field content with a reversed-video block as the cursor.
508fn 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
527/// Renders a flag vector so token boundaries survive: `shell_words::join`.
528fn 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}