From 925d0b66e1969b09d76a5d7ad8626d36c367675f Mon Sep 17 00:00:00 2001 From: Hayden Stainsby Date: Fri, 2 Jun 2023 00:46:37 +0200 Subject: [PATCH] refac(console): generalize controls widget (#427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each view in tokio-console has a widget up the top that lists the available controls for that view. There was a common implementation of this for table based views (tasks, resources, and async_ops) and separate implementations for the task and resource views. The resource view included two controls widgets, one for the Resource details at the top of the view, and another above the table of Async Ops at the bottom of the view. This change centralises the logic for the creation of this controls widget. This change is mostly a precursor to also displaying the controls in the help view (at which point we can revisit whether the entire list needs to be shown at the top of the screen). Controls (an action and the key or keys used to invoke it) are defined in structs so that their definition can be separated from the display logic (which includes whether or not UTF-8 is supported). This allows the problem of the text in the controls widget wrapping in the middle of a control definition to be fixed. Previously a subset of the controls would have wrapped like this: ```text controls: select column (sort) = ←→ or h, l, scroll = ↑↓ or k, j, view details = ↵, invert sort (highest/lowest) = i, ``` Notice how "view details = ↵," was split across multiple lines. The same list of controls will now wrap at a full control definition. ```text controls: select column (sort) = ←→ or h, l, scroll = ↑↓ or k, j, view details = ↵, invert sort (highest/lowest) = i, ``` Additionally, the list of controls on the Resource view has been consolidated up the top of the screen. Universal controls, those that are available in all views, are also defined centrally. As well as the quit action, using the space bar to pause has been added to that list. This was previously somewhat of an undocumented feature. --- tokio-console/src/view/async_ops.rs | 24 +---- tokio-console/src/view/controls.rs | 145 ++++++++++++++++++++++++++++ tokio-console/src/view/mod.rs | 1 + tokio-console/src/view/resource.rs | 37 ++++--- tokio-console/src/view/resources.rs | 9 +- tokio-console/src/view/table.rs | 123 +++++++++++------------ tokio-console/src/view/task.rs | 34 ++++--- tokio-console/src/view/tasks.rs | 11 ++- 8 files changed, 268 insertions(+), 116 deletions(-) create mode 100644 tokio-console/src/view/controls.rs diff --git a/tokio-console/src/view/async_ops.rs b/tokio-console/src/view/async_ops.rs index d59eaf88b..000319cc3 100644 --- a/tokio-console/src/view/async_ops.rs +++ b/tokio-console/src/view/async_ops.rs @@ -1,3 +1,4 @@ +pub(crate) use crate::view::table::view_controls; use crate::{ state::{ async_ops::{AsyncOp, SortBy}, @@ -6,7 +7,7 @@ use crate::{ }, view::{ self, bold, - table::{self, TableList, TableListState}, + table::{TableList, TableListState}, DUR_LEN, DUR_TABLE_PRECISION, }, }; @@ -193,24 +194,6 @@ impl TableList<9> for AsyncOpsTable { table_list_state.len() ))]); - let layout = layout::Layout::default() - .direction(layout::Direction::Vertical) - .margin(0); - - let controls = table::Controls::for_area(&area, styles); - let chunks = layout - .constraints( - [ - layout::Constraint::Length(controls.height), - layout::Constraint::Max(area.height), - ] - .as_ref(), - ) - .split(area); - - let controls_area = chunks[0]; - let async_ops_area = chunks[1]; - let attributes_width = layout::Constraint::Percentage(100); let widths = &[ id_width.constraint(), @@ -231,8 +214,7 @@ impl TableList<9> for AsyncOpsTable { .highlight_symbol(view::TABLE_HIGHLIGHT_SYMBOL) .highlight_style(Style::default().add_modifier(style::Modifier::BOLD)); - frame.render_stateful_widget(table, async_ops_area, &mut table_list_state.table_state); - frame.render_widget(controls.paragraph, controls_area); + frame.render_stateful_widget(table, area, &mut table_list_state.table_state); table_list_state .sorted_items diff --git a/tokio-console/src/view/controls.rs b/tokio-console/src/view/controls.rs new file mode 100644 index 000000000..81a90a7e2 --- /dev/null +++ b/tokio-console/src/view/controls.rs @@ -0,0 +1,145 @@ +use crate::view::{self, bold}; + +use ratatui::{ + layout, + text::{Span, Spans, Text}, + widgets::{Paragraph, Widget}, +}; + +/// A list of controls which are available in all views. +const UNIVERSAL_CONTROLS: &[ControlDisplay] = &[ + ControlDisplay { + action: "toggle pause", + keys: &[KeyDisplay { + base: "space", + utf8: None, + }], + }, + ControlDisplay { + action: "quit", + keys: &[KeyDisplay { + base: "q", + utf8: None, + }], + }, +]; + +/// Construct a widget to display the controls available to the user in the +/// current view. +pub(crate) struct Controls { + paragraph: Paragraph<'static>, + height: u16, +} + +impl Controls { + pub(in crate::view) fn new( + view_controls: &'static [ControlDisplay], + area: &layout::Rect, + styles: &view::Styles, + ) -> Self { + let mut spans_controls = Vec::with_capacity(view_controls.len() + UNIVERSAL_CONTROLS.len()); + spans_controls.extend(view_controls.iter().map(|c| c.to_spans(styles))); + spans_controls.extend(UNIVERSAL_CONTROLS.iter().map(|c| c.to_spans(styles))); + + let mut lines = vec![Spans::from(vec![Span::from("controls: ")])]; + let mut current_line = lines.last_mut().expect("This vector is never empty"); + let separator = Span::from(", "); + + let controls_count: usize = spans_controls.len(); + for (idx, spans) in spans_controls.into_iter().enumerate() { + // If this is the first item on this line - or first item on the + // first line, then always include it - even if it goes beyond the + // line width, not much we can do anyway. + if idx == 0 || current_line.width() == 0 { + current_line.0.extend(spans.0); + continue; + } + + // Include the width of our separator in the current item if we + // aren't placing the last item. This is the separator after the + // new element. + let needed_trailing_separator_width = if idx == controls_count + 1 { + separator.width() + } else { + 0 + }; + + let total_width = current_line.width() + + separator.width() + + spans.width() + + needed_trailing_separator_width; + + // If the current item fits on this line, append it. + // Otherwise, append only the separator - we accounted for its + // width in the previous loop iteration - and then create a new + // line for the current item. + if total_width <= area.width as usize { + current_line.0.push(separator.clone()); + current_line.0.extend(spans.0); + } else { + current_line.0.push(separator.clone()); + lines.push(spans); + current_line = lines.last_mut().expect("This vector is never empty"); + } + } + + let height = lines.len() as u16; + let text = Text::from(lines); + + Self { + paragraph: Paragraph::new(text), + height, + } + } + + pub(crate) fn height(&self) -> u16 { + self.height + } + + pub(crate) fn into_widget(self) -> impl Widget { + self.paragraph + } +} + +/// Construct span to display a control. +/// +/// A control is made up of an action and one or more keys that will trigger +/// that action. +#[derive(Clone)] +pub(crate) struct ControlDisplay { + pub(crate) action: &'static str, + pub(crate) keys: &'static [KeyDisplay], +} + +/// A key or keys which will be displayed to the user as part of spans +/// constructed by `ControlDisplay`. +/// +/// The `base` description of the key should be ASCII only, more advanced +/// descriptions can be supplied for that key in the `utf8` field. This +/// allows the application to pick the best one to display at runtime +/// based on the termainal being used. +#[derive(Clone)] +pub(crate) struct KeyDisplay { + pub(crate) base: &'static str, + pub(crate) utf8: Option<&'static str>, +} + +impl ControlDisplay { + pub(crate) fn to_spans(&self, styles: &view::Styles) -> Spans<'static> { + let mut spans = Vec::new(); + + spans.push(Span::from(self.action)); + spans.push(Span::from(" = ")); + for (idx, key_display) in self.keys.iter().enumerate() { + if idx > 0 { + spans.push(Span::from(" or ")) + } + spans.push(bold(match key_display.utf8 { + Some(utf8) => styles.if_utf8(utf8, key_display.base), + None => key_display.base, + })); + } + + Spans::from(spans) + } +} diff --git a/tokio-console/src/view/mod.rs b/tokio-console/src/view/mod.rs index 47c345285..ca9e87fcc 100644 --- a/tokio-console/src/view/mod.rs +++ b/tokio-console/src/view/mod.rs @@ -8,6 +8,7 @@ use ratatui::{ use std::{borrow::Cow, cmp}; mod async_ops; +mod controls; mod durations; mod mini_histogram; mod percentiles; diff --git a/tokio-console/src/view/resource.rs b/tokio-console/src/view/resource.rs index 0a5f830d9..05bc7f68b 100644 --- a/tokio-console/src/view/resource.rs +++ b/tokio-console/src/view/resource.rs @@ -4,14 +4,17 @@ use crate::{ state::State, view::{ self, - async_ops::{AsyncOpsTable, AsyncOpsTableCtx}, - bold, TableListState, + async_ops::{self, AsyncOpsTable, AsyncOpsTableCtx}, + bold, + controls::{ControlDisplay, Controls, KeyDisplay}, + TableListState, }, }; +use once_cell::sync::OnceCell; use ratatui::{ layout::{self, Layout}, text::{Span, Spans, Text}, - widgets::{Block, Paragraph}, + widgets::Paragraph, }; use std::{cell::RefCell, rc::Rc}; @@ -42,6 +45,7 @@ impl ResourceView { state: &mut State, ) { let resource = &*self.resource.borrow(); + let controls = Controls::new(view_controls(), &area, styles); let (controls_area, stats_area, async_ops_area) = { let chunks = Layout::default() @@ -49,7 +53,7 @@ impl ResourceView { .constraints( [ // controls - layout::Constraint::Length(1), + layout::Constraint::Length(controls.height()), // resource stats layout::Constraint::Length(8), // async ops @@ -72,14 +76,6 @@ impl ResourceView { ) .split(stats_area); - let controls = Spans::from(vec![ - Span::raw("controls: "), - bold(styles.if_utf8("\u{238B} esc", "esc")), - Span::raw(" = return to task list, "), - bold("q"), - Span::raw(" = quit"), - ]); - let overview = vec![ Spans::from(vec![bold("ID: "), Span::raw(resource.id_str())]), Spans::from(vec![bold("Parent ID: "), Span::raw(resource.parent())]), @@ -107,7 +103,7 @@ impl ResourceView { Paragraph::new(overview).block(styles.border_block().title("Resource")); let fields_widget = Paragraph::new(fields).block(styles.border_block().title("Attributes")); - frame.render_widget(Block::default().title(controls), controls_area); + frame.render_widget(controls.into_widget(), controls_area); frame.render_widget(resource_widget, stats_area[0]); frame.render_widget(fields_widget, stats_area[1]); let ctx = AsyncOpsTableCtx { @@ -119,3 +115,18 @@ impl ResourceView { self.initial_render = false; } } + +fn view_controls() -> &'static [ControlDisplay] { + static VIEW_CONTROLS: OnceCell> = OnceCell::new(); + + VIEW_CONTROLS.get_or_init(|| { + let resource_controls = &[ControlDisplay { + action: "return to task list", + keys: &[KeyDisplay { + base: "esc", + utf8: Some("\u{238B} esc"), + }], + }]; + [resource_controls, async_ops::view_controls()].concat() + }) +} diff --git a/tokio-console/src/view/resources.rs b/tokio-console/src/view/resources.rs index 4248de3d5..124ba22d5 100644 --- a/tokio-console/src/view/resources.rs +++ b/tokio-console/src/view/resources.rs @@ -5,7 +5,8 @@ use crate::{ }, view::{ self, bold, - table::{self, TableList, TableListState}, + controls::Controls, + table::{view_controls, TableList, TableListState}, DUR_LEN, DUR_TABLE_PRECISION, }, }; @@ -163,7 +164,7 @@ impl TableList<9> for ResourcesTable { table_list_state.len() ))]); - let controls = table::Controls::for_area(&area, styles); + let controls = Controls::new(view_controls(), &area, styles); let layout = layout::Layout::default() .direction(layout::Direction::Vertical) @@ -172,7 +173,7 @@ impl TableList<9> for ResourcesTable { let chunks = layout .constraints( [ - layout::Constraint::Length(controls.height), + layout::Constraint::Length(controls.height()), layout::Constraint::Max(area.height), ] .as_ref(), @@ -202,7 +203,7 @@ impl TableList<9> for ResourcesTable { .highlight_style(Style::default().add_modifier(style::Modifier::BOLD)); frame.render_stateful_widget(table, tasks_area, &mut table_list_state.table_state); - frame.render_widget(controls.paragraph, controls_area); + frame.render_widget(controls.into_widget(), controls_area); table_list_state .sorted_items diff --git a/tokio-console/src/view/table.rs b/tokio-console/src/view/table.rs index 7f1a7548e..1f36a25b8 100644 --- a/tokio-console/src/view/table.rs +++ b/tokio-console/src/view/table.rs @@ -1,15 +1,14 @@ use crate::{ input, state, - view::{self, bold}, + view::{ + self, + controls::{ControlDisplay, KeyDisplay}, + }, }; -use ratatui::{ - layout, - text::{self, Span, Spans, Text}, - widgets::{Paragraph, TableState, Wrap}, -}; -use std::convert::TryFrom; +use ratatui::{layout, widgets::TableState}; use std::cell::RefCell; +use std::convert::TryFrom; use std::rc::Weak; pub(crate) trait TableList { @@ -45,11 +44,6 @@ pub(crate) struct TableListState, const N: usize> { last_key_event: Option, } -pub(crate) struct Controls { - pub(crate) paragraph: Paragraph<'static>, - pub(crate) height: u16, -} - impl, const N: usize> TableListState { pub(in crate::view) fn len(&self) -> usize { self.sorted_items.len() @@ -201,52 +195,61 @@ where } } -impl Controls { - pub(in crate::view) fn for_area(area: &layout::Rect, styles: &view::Styles) -> Self { - let text = Text::from(Spans::from(vec![ - Span::raw("controls: "), - bold(styles.if_utf8("\u{2190}\u{2192}", "left, right")), - Span::raw(" or "), - bold("h, l"), - text::Span::raw(" = select column (sort), "), - bold(styles.if_utf8("\u{2191}\u{2193}", "up, down")), - Span::raw(" or "), - bold("k, j"), - text::Span::raw(" = scroll, "), - bold(styles.if_utf8("\u{21B5}", "enter")), - text::Span::raw(" = view details, "), - bold("i"), - text::Span::raw(" = invert sort (highest/lowest), "), - bold("q"), - text::Span::raw(" = quit "), - bold("gg"), - text::Span::raw(" = scroll to top, "), - bold("G"), - text::Span::raw(" = scroll to bottom"), - ])); - - // how many lines do we need to display the controls? - let mut height = 1; - - // if the area is narrower than the width of the controls text, we need - // to wrap the text across multiple lines. - let width = text.width() as u16; - if area.width < width { - height = width / area.width; - - // if the text's width is not neatly divisible by the area's width - // (and it almost never will be), round up for the remaining text. - if width % area.width > 0 { - height += 1 - }; - } - - Self { - // TODO(eliza): it would be nice if we could wrap this on commas, - // specifically, rather than whitespace...but that seems like a - // bunch of additional work... - paragraph: Paragraph::new(text).wrap(Wrap { trim: true }), - height, - } - } +pub(crate) const fn view_controls() -> &'static [ControlDisplay] { + &[ + ControlDisplay { + action: "select column (sort)", + keys: &[ + KeyDisplay { + base: "left, right", + utf8: Some("\u{2190}\u{2192}"), + }, + KeyDisplay { + base: "h, l", + utf8: None, + }, + ], + }, + ControlDisplay { + action: "scroll", + keys: &[ + KeyDisplay { + base: "up, down", + utf8: Some("\u{2191}\u{2193}"), + }, + KeyDisplay { + base: "k, j", + utf8: None, + }, + ], + }, + ControlDisplay { + action: "view details", + keys: &[KeyDisplay { + base: "enter", + utf8: Some("\u{21B5}"), + }], + }, + ControlDisplay { + action: "invert sort (highest/lowest)", + keys: &[KeyDisplay { + base: "i", + utf8: None, + }], + }, + ControlDisplay { + action: "scroll to top", + keys: &[KeyDisplay { + base: "gg", + utf8: None, + }], + }, + ControlDisplay { + action: "scroll to bottom", + keys: &[KeyDisplay { + base: "G", + utf8: None, + }], + }, + ] } diff --git a/tokio-console/src/view/task.rs b/tokio-console/src/view/task.rs index d9368c0c7..c3c50fae5 100644 --- a/tokio-console/src/view/task.rs +++ b/tokio-console/src/view/task.rs @@ -2,12 +2,16 @@ use crate::{ input, state::{tasks::Task, DetailsRef}, util::Percentage, - view::{self, bold, durations::Durations}, + view::{ + self, bold, + controls::{ControlDisplay, Controls, KeyDisplay}, + durations::Durations, + }, }; use ratatui::{ layout::{self, Layout}, text::{Span, Spans, Text}, - widgets::{Block, List, ListItem, Paragraph}, + widgets::{List, ListItem, Paragraph}, }; use std::{ cell::RefCell, @@ -49,6 +53,8 @@ impl TaskView { .as_ref() .filter(|details| details.span_id() == task.span_id()); + let controls = Controls::new(view_controls(), &area, styles); + let warnings: Vec<_> = task .warnings() .iter() @@ -74,7 +80,7 @@ impl TaskView { .constraints( [ // controls - layout::Constraint::Length(1), + layout::Constraint::Length(controls.height()), // task stats layout::Constraint::Length(10), // poll duration @@ -94,7 +100,7 @@ impl TaskView { .constraints( [ // controls - layout::Constraint::Length(1), + layout::Constraint::Length(controls.height()), // warnings (add 2 for top and bottom borders) layout::Constraint::Length(warnings.len() as u16 + 2), // task stats @@ -131,14 +137,6 @@ impl TaskView { ) .split(stats_area); - let controls = Spans::from(vec![ - Span::raw("controls: "), - bold(styles.if_utf8("\u{238B} esc", "esc")), - Span::raw(" = return to task list, "), - bold("q"), - Span::raw(" = quit"), - ]); - // Just preallocate capacity for ID, name, target, total, busy, and idle. let mut overview = Vec::with_capacity(8); overview.push(Spans::from(vec![ @@ -246,7 +244,7 @@ impl TaskView { let fields_widget = Paragraph::new(fields).block(styles.border_block().title("Fields")); - frame.render_widget(Block::default().title(controls), controls_area); + frame.render_widget(controls.into_widget(), controls_area); frame.render_widget(task_widget, stats_area[0]); frame.render_widget(wakers_widget, stats_area[1]); frame.render_widget(poll_durations_widget, poll_dur_area); @@ -254,3 +252,13 @@ impl TaskView { frame.render_widget(fields_widget, fields_area); } } + +const fn view_controls() -> &'static [ControlDisplay] { + &[ControlDisplay { + action: "return to task list", + keys: &[KeyDisplay { + base: "esc", + utf8: Some("\u{238B} esc"), + }], + }] +} diff --git a/tokio-console/src/view/tasks.rs b/tokio-console/src/view/tasks.rs index 078ebca0b..7d15934ac 100644 --- a/tokio-console/src/view/tasks.rs +++ b/tokio-console/src/view/tasks.rs @@ -5,7 +5,8 @@ use crate::{ }, view::{ self, bold, - table::{self, TableList, TableListState}, + controls::Controls, + table::{view_controls, TableList, TableListState}, DUR_LEN, DUR_TABLE_PRECISION, }, }; @@ -212,13 +213,13 @@ impl TableList<12> for TasksTable { .direction(layout::Direction::Vertical) .margin(0); - let controls = table::Controls::for_area(&area, styles); + let controls = Controls::new(view_controls(), &area, styles); let (controls_area, tasks_area, warnings_area) = if warnings.is_empty() { let chunks = layout .constraints( [ - layout::Constraint::Length(controls.height), + layout::Constraint::Length(controls.height()), layout::Constraint::Max(area.height), ] .as_ref(), @@ -230,7 +231,7 @@ impl TableList<12> for TasksTable { let chunks = layout .constraints( [ - layout::Constraint::Length(controls.height), + layout::Constraint::Length(controls.height()), layout::Constraint::Length(warnings_height), layout::Constraint::Max(area.height), ] @@ -269,7 +270,7 @@ impl TableList<12> for TasksTable { .highlight_style(Style::default().add_modifier(style::Modifier::BOLD)); frame.render_stateful_widget(table, tasks_area, &mut table_list_state.table_state); - frame.render_widget(controls.paragraph, controls_area); + frame.render_widget(controls.into_widget(), controls_area); if let Some(area) = warnings_area { let block = styles