From 8a7c2cc592d6492ea912b28dda84a2404d70b1f5 Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 10 May 2023 16:20:28 -0700 Subject: [PATCH 1/3] =?UTF-8?q?[=F0=9D=98=80=F0=9D=97=BD=F0=9D=97=BF]=20in?= =?UTF-8?q?itial=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created using spr 1.3.4 --- Cargo.lock | 40 +++- Cargo.toml | 2 + wicket/Cargo.toml | 3 + wicket/src/ui/mod.rs | 1 + wicket/src/ui/panes/update.rs | 12 +- wicket/src/ui/widgets/popup.rs | 34 +++- wicket/src/ui/wrap.rs | 357 +++++++++++++++++++++++++++++++++ 7 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 wicket/src/ui/wrap.rs diff --git a/Cargo.lock b/Cargo.lock index ec64db15ae..e5514134c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom 0.2.8", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.3" @@ -2792,6 +2803,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.6", +] [[package]] name = "hashbrown" @@ -2799,7 +2813,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash", + "ahash 0.8.3", ] [[package]] @@ -7541,6 +7555,12 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + [[package]] name = "smf" version = "0.2.1" @@ -8045,6 +8065,11 @@ name = "textwrap" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] [[package]] name = "thiserror" @@ -8762,6 +8787,16 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +[[package]] +name = "unicode-linebreak" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" +dependencies = [ + "hashbrown 0.12.3", + "regex", +] + [[package]] name = "unicode-normalization" version = "0.1.22" @@ -9200,6 +9235,7 @@ dependencies = [ "hex", "humantime", "indexmap", + "itertools", "libsw", "omicron-common 0.1.0", "once_cell", @@ -9217,11 +9253,13 @@ dependencies = [ "snafu", "tar", "tempfile", + "textwrap 0.16.0", "tokio", "tokio-util", "toml 0.7.3", "tui", "tui-tree-widget", + "unicode-width", "update-engine", "wicket-common", "wicketd-client", diff --git a/Cargo.toml b/Cargo.toml index 6686efba7b..a7189b3a14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -309,6 +309,7 @@ tempdir = "0.3" tempfile = "3.5" term = "0.7" termios = "0.3" +textwrap = "0.16.0" test-strategy = "0.2.1" thiserror = "1.0" tofino = { git = "http://github.com/oxidecomputer/tofino", branch = "main" } @@ -327,6 +328,7 @@ trybuild = "1.0.80" tufaceous = { path = "tufaceous" } tufaceous-lib = { path = "tufaceous-lib" } tui = "0.19.0" +unicode-width = "0.1.10" update-engine = { path = "update-engine" } uuid = { version = "1.3.2", features = ["serde", "v4"] } usdt = "0.3" diff --git a/wicket/Cargo.toml b/wicket/Cargo.toml index f49b08d9ce..d7893c84a3 100644 --- a/wicket/Cargo.toml +++ b/wicket/Cargo.toml @@ -17,6 +17,7 @@ futures.workspace = true hex = { workspace = true, features = ["serde"] } humantime.workspace = true indexmap.workspace = true +itertools.workspace = true omicron-common.workspace = true once_cell.workspace = true reqwest.workspace = true @@ -32,11 +33,13 @@ slog-term.workspace = true snafu.workspace = true libsw.workspace = true tar.workspace = true +textwrap.workspace = true tokio = { workspace = true, features = ["full"] } tokio-util.workspace = true toml.workspace = true tui.workspace = true tui-tree-widget = "0.11.0" +unicode-width.workspace = true update-engine.workspace = true wicket-common.workspace = true diff --git a/wicket/src/ui/mod.rs b/wicket/src/ui/mod.rs index 360f32b440..45e2f0f74b 100644 --- a/wicket/src/ui/mod.rs +++ b/wicket/src/ui/mod.rs @@ -8,6 +8,7 @@ mod main; mod panes; mod splash; mod widgets; +mod wrap; use crate::{Action, Cmd, State, Term}; use slog::{o, Logger}; diff --git a/wicket/src/ui/panes/update.rs b/wicket/src/ui/panes/update.rs index cb4d993721..60a9bf8898 100644 --- a/wicket/src/ui/panes/update.rs +++ b/wicket/src/ui/panes/update.rs @@ -13,6 +13,7 @@ use crate::ui::defaults::style; use crate::ui::widgets::{ BoxConnector, BoxConnectorKind, ButtonText, IgnitionPopup, Popup, }; +use crate::ui::wrap::wrap_text; use crate::{Action, Cmd, Frame, State}; use indexmap::IndexMap; use omicron_common::api::internal::nexus::KnownArtifactKind; @@ -342,9 +343,18 @@ impl UpdatePane { } } + // Wrap the text to the maximum popup width. + let options = crate::ui::wrap::Options { + width: Popup::max_content_width(state.screen_width) as usize, + initial_indent: Span::raw(""), + subsequent_indent: Span::raw(""), + break_words: true, + }; + let wrapped_body = wrap_text(&body, options); + let popup = Popup { header, - body, + body: wrapped_body, buttons: vec![ButtonText { instruction: "NAVIGATE", key: "LEFT/RIGHT", diff --git a/wicket/src/ui/widgets/popup.rs b/wicket/src/ui/widgets/popup.rs index b852f0f805..e0106af513 100644 --- a/wicket/src/ui/widgets/popup.rs +++ b/wicket/src/ui/widgets/popup.rs @@ -9,7 +9,7 @@ use tui::{ layout::{Constraint, Direction, Layout, Rect}, style::Style, text::{Span, Spans, Text}, - widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap}, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget}, }; use crate::ui::defaults::dimensions::RectExt; @@ -69,6 +69,28 @@ impl Popup<'_> { }); u16::try_from(width).unwrap() } + + /// Returns the maximum width that this popup can have, including outer + /// borders. + /// + /// This is currently 80% of screen width. + pub fn max_width(full_screen_width: u16) -> u16 { + (full_screen_width as u32 * 4 / 5) as u16 + } + + /// Returns the maximum width that this popup can have, not including outer + /// borders. + pub fn max_content_width(full_screen_width: u16) -> u16 { + Self::max_width(full_screen_width).saturating_sub(2) + } + + /// Returns the maximum height that this popup can have, including outer + /// borders. + /// + /// This is currently 80% of screen height. + pub fn max_height(full_screen_height: u16) -> u16 { + (full_screen_height as u32 * 4 / 5) as u16 + } } impl Widget for Popup<'_> { @@ -81,10 +103,9 @@ impl Widget for Popup<'_> { .border_type(BorderType::Rounded) .style(style::selected_line()); - // Constrain width and height to 80% of screen width. Width and height - // are u16s and less than 128 so the computation shouldn't overflow. - let width = u16::min(self.width(), full_screen.width * 4 / 5); - let height = u16::min(self.height(), full_screen.height * 4 / 5); + let width = u16::min(self.width(), Self::max_width(full_screen.width)); + let height = + u16::min(self.height(), Self::max_height(full_screen.height)); let rect = full_screen.center_horizontally(width).center_vertically(height); @@ -116,7 +137,8 @@ impl Widget for Popup<'_> { .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) .render(chunks[1], buf); - let body = Paragraph::new(self.body).wrap(Wrap { trim: false }); + // NOTE: wrapping should be performed externally, by e.g. wrap_text. + let body = Paragraph::new(self.body); let mut body_rect = chunks[1]; // Ensure we're inside the outer border. diff --git a/wicket/src/ui/wrap.rs b/wicket/src/ui/wrap.rs new file mode 100644 index 0000000000..5e466321fb --- /dev/null +++ b/wicket/src/ui/wrap.rs @@ -0,0 +1,357 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! This module is an adaptation of textwrap's `Word` library to work with +//! `tui`'s `Span`s and lines. +//! +//! The code is mostly copy-pasted, with the following changes: +//! +//! * [`textwrap::core::Word`] is now [`StyledWord`]. +//! * Hyphenation is no longer supported. +//! +//! Currently, each element of `tui`'s [`Text::lines`] is assumed to be a +//! separate line. We don't check for whether a line's content has embedded +//! newlines in it, but we could in the future if necessary. (Embedded newlines +//! won't break the output, but they might make the output look a bit weird.) + +use itertools::{Itertools, Position}; +use textwrap::{ + core::{display_width, Fragment}, + wrap_algorithms::{wrap_optimal_fit, Penalties}, +}; +use tui::text::{Span, Spans, Text}; + +pub(crate) struct Options<'a> { + /// The width in columns at which the text will be wrapped. + pub(crate) width: usize, + /// Indentation used for the first line of output. + pub(crate) initial_indent: Span<'a>, + /// Indentation used for subsequent lines of output. + pub(crate) subsequent_indent: Span<'a>, + /// Allow long words to be broken if they cannot fit on a line. + /// When set to `false`, some lines may be longer than + /// `self.width`. + pub(crate) break_words: bool, +} + +pub(crate) fn wrap_text<'a>( + text: &'a Text<'_>, + options: Options<'a>, +) -> Text<'a> { + let mut lines = Vec::new(); + // We currently assume that lines in text don't have embedded newlines in + // them. This assumption might need to be revisited. + for line in &text.lines { + wrap_single_line(line, &options, &mut lines); + } + + Text::from(lines) +} + +fn wrap_single_line<'a>( + line: &'a Spans<'_>, + options: &Options<'a>, + lines: &mut Vec>, +) { + let indent = if lines.is_empty() { + options.initial_indent.clone() + } else { + options.subsequent_indent.clone() + }; + if line.width() < options.width && indent.content.is_empty() { + lines.push(borrow_line(line)); + } else { + wrap_single_line_slow_path(line, options, lines) + } +} + +fn borrow_line<'a>(line: &'a Spans<'_>) -> Spans<'a> { + let spans = line + .0 + .iter() + .map(|span| Span::styled(span.content.as_ref(), span.style)) + .collect(); + Spans(spans) +} + +fn wrap_single_line_slow_path<'a>( + line: &'a Spans<'_>, + options: &Options<'a>, + lines: &mut Vec>, +) { + // Span::width (options.initial_indent.width() etc) use the Unicode display + // width, which is what we expect it. + let initial_width = + options.width.saturating_sub(options.initial_indent.width()); + let subsequent_width = + options.width.saturating_sub(options.subsequent_indent.width()); + let line_widths = [initial_width, subsequent_width]; + + let split_words = find_words_in_line(&line); + + // We don't perform any word splitting. + let broken_words = if options.break_words { + let mut broken_words = break_words(split_words, line_widths[1]); + if !options.initial_indent.content.is_empty() { + // Without this, the first word will always go into the + // first line. However, since we break words based on the + // _second_ line width, it can be wrong to unconditionally + // put the first word onto the first line. An empty + // zero-width word fixed this. + broken_words.insert(0, StyledWord::empty()); + } + broken_words + } else { + split_words.collect::>() + }; + + let f64_line_widths = + line_widths.iter().map(|w| *w as f64).collect::>(); + + // The optimal fit wrap looks nicer, and we're wrapping pretty small amounts + // of text so performance is unlikely to be an issue. + let wrapped_lines = + wrap_optimal_fit(&broken_words, &f64_line_widths, &Penalties::new()) + .expect("computation cannot overflow with restricted line widths"); + + for words in wrapped_lines { + let mut output_line = Vec::new(); + + if lines.is_empty() && !options.initial_indent.content.is_empty() { + output_line.push(options.initial_indent.clone()); + } else if !lines.is_empty() + && !options.subsequent_indent.content.is_empty() + { + output_line.push(options.subsequent_indent.clone()); + } + + for word in words.into_iter().with_position() { + match word { + Position::First(word) | Position::Middle(word) => { + output_line.extend(word.word_span()); + output_line.extend(word.whitespace_span()); + } + Position::Last(word) | Position::Only(word) => { + // Don't add trailing whitespace, just the content. + output_line.extend(word.word_span()); + // We don't support hyphenation at the moment, but if we + // did, this is where they would go. + } + } + } + + lines.push(Spans::from(output_line)); + } +} + +fn find_words_in_line<'a, 'b>( + line: &'a Spans<'b>, +) -> impl Iterator> { + line.0.iter().flat_map(|span| find_words_in_span(span)) +} + +/// Breaks this span into smaller words. +/// +/// This assumes the only word breaks are ASCII spaces. In particular, it +/// assume that there are no newlines anywhere within a span. +fn find_words_in_span<'a, 'b>( + span: &'a Span<'b>, +) -> impl Iterator> { + let mut start = 0; + let mut in_whitespace = false; + let mut char_indices = span.content.char_indices(); + + std::iter::from_fn(move || { + for (idx, ch) in char_indices.by_ref() { + if in_whitespace && ch != ' ' { + let word = StyledWord::new_sub_span(span, start, idx); + start = idx; + in_whitespace = ch == ' '; + return Some(word); + } + + in_whitespace = ch == ' '; + } + + let content_len = span.content.len(); + if start < content_len { + let word = StyledWord::new_sub_span(span, start, content_len); + start = content_len; + return Some(word); + } + + None + }) +} + +/// A word with a style associated with it. +/// +/// This is similar to a [`textwrap::core::Word`], except each word also has a +/// style associated with it. +#[derive(Copy, Clone, Debug)] +struct StyledWord<'a> { + word: &'a str, + width: usize, + whitespace: &'a str, + style: tui::style::Style, +} + +impl<'a> StyledWord<'a> { + #[allow(unused)] + fn new(span: &'a Span<'_>) -> Self { + // We assume the whitespace consists of ' ' only. This allows us to + // compute the display width in constant time. + Self::new_impl(&span.content, span.style) + } + + fn new_sub_span(span: &'a Span<'_>, start: usize, end: usize) -> Self { + let content = &span.content[start..end]; + Self::new_impl(content, span.style) + } + + fn new_impl(content: &'a str, style: tui::style::Style) -> Self { + let trimmed = content.trim_end_matches(' '); + Self { + word: trimmed, + width: display_width(trimmed), + whitespace: &content[trimmed.len()..], + style, + } + } + + fn empty() -> Self { + Self { + word: "", + width: 0, + whitespace: "", + style: tui::style::Style::default(), + } + } + + fn word_span(&self) -> Option> { + (!self.word.is_empty()).then(|| Span::styled(self.word, self.style)) + } + + fn whitespace_span(&self) -> Option> { + (!self.whitespace.is_empty()) + .then(|| Span::styled(self.whitespace, self.style)) + } + + /// Break this span into smaller words with a width of at most `line_width`. + /// The whitespace from this `SpanWord` is added to the last piece. + fn break_apart<'b>( + &'b self, + line_width: usize, + ) -> impl Iterator> + 'b { + let mut char_indices = self.word.char_indices(); + let mut offset = 0; + let mut width = 0; + + std::iter::from_fn(move || { + while let Some((idx, ch)) = char_indices.next() { + if skip_ansi_escape_sequence( + ch, + &mut char_indices.by_ref().map(|(_, ch)| ch), + ) { + continue; + } + + if width > 0 && width + ch_width(ch) > line_width { + let word = StyledWord { + word: &self.word[offset..idx], + width, + whitespace: "", + style: self.style, + }; + offset = idx; + width = ch_width(ch); + return Some(word); + } + + width += ch_width(ch); + } + + if offset < self.word.len() { + let word = StyledWord { + word: &self.word[offset..], + width, + whitespace: self.whitespace, + style: self.style, + }; + offset = self.word.len(); + return Some(word); + } + + None + }) + } +} + +impl<'a> Fragment for StyledWord<'a> { + fn width(&self) -> f64 { + // self.width is the display width, which is what we care about here. + self.width as f64 + } + + fn whitespace_width(&self) -> f64 { + // Since whitespace is always ASCII spaces, this is + self.whitespace.len() as f64 + } + + fn penalty_width(&self) -> f64 { + // We don't insert hyphens or anything similar else -- just use 0.0 + // here. + 0.0 + } +} + +/// Forcibly break spans wider than `line_width` into smaller spans. +/// +/// This simply calls [`Span::break_apart`] on spans that are too wide. +fn break_words<'a, I>(spans: I, line_width: usize) -> Vec> +where + I: IntoIterator>, +{ + let mut shortened_spans = Vec::new(); + for span in spans { + if span.width() > line_width as f64 { + shortened_spans.extend(span.break_apart(line_width)); + } else { + shortened_spans.push(span); + } + } + shortened_spans +} + +/// The CSI or “Control Sequence Introducer” introduces an ANSI escape +/// sequence. This is typically used for colored text and will be +/// ignored when computing the text width. +const CSI: (char, char) = ('\x1b', '['); +/// The final bytes of an ANSI escape sequence must be in this range. +const ANSI_FINAL_BYTE: std::ops::RangeInclusive = '\x40'..='\x7e'; + +/// Skip ANSI escape sequences. The `ch` is the current `char`, the +/// `chars` provide the following characters. The `chars` will be +/// modified if `ch` is the start of an ANSI escape sequence. +#[inline] +fn skip_ansi_escape_sequence>( + ch: char, + chars: &mut I, +) -> bool { + if ch == CSI.0 && chars.next() == Some(CSI.1) { + // We have found the start of an ANSI escape code, typically + // used for colored terminal text. We skip until we find a + // "final byte" in the range 0x40–0x7E. + for ch in chars { + if ANSI_FINAL_BYTE.contains(&ch) { + return true; + } + } + } + false +} + +fn ch_width(ch: char) -> usize { + unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) +} From e0ca0586a1abd4382e4936c79dc0758e825e1a5d Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 10 May 2023 16:21:50 -0700 Subject: [PATCH 2/3] Fix clippy Created using spr 1.3.4 --- wicket/src/ui/wrap.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wicket/src/ui/wrap.rs b/wicket/src/ui/wrap.rs index 5e466321fb..4ead995d4f 100644 --- a/wicket/src/ui/wrap.rs +++ b/wicket/src/ui/wrap.rs @@ -145,8 +145,8 @@ fn wrap_single_line_slow_path<'a>( } } -fn find_words_in_line<'a, 'b>( - line: &'a Spans<'b>, +fn find_words_in_line<'a>( + line: &'a Spans<'_>, ) -> impl Iterator> { line.0.iter().flat_map(|span| find_words_in_span(span)) } @@ -155,8 +155,8 @@ fn find_words_in_line<'a, 'b>( /// /// This assumes the only word breaks are ASCII spaces. In particular, it /// assume that there are no newlines anywhere within a span. -fn find_words_in_span<'a, 'b>( - span: &'a Span<'b>, +fn find_words_in_span<'a>( + span: &'a Span<'_>, ) -> impl Iterator> { let mut start = 0; let mut in_whitespace = false; From 2eb3fa3a532bc2f02ba1f7106835bda3ffb31468 Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 10 May 2023 16:37:06 -0700 Subject: [PATCH 3/3] fix typo in comment Created using spr 1.3.4 --- wicket/src/ui/wrap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wicket/src/ui/wrap.rs b/wicket/src/ui/wrap.rs index 4ead995d4f..53be65f0e3 100644 --- a/wicket/src/ui/wrap.rs +++ b/wicket/src/ui/wrap.rs @@ -81,7 +81,7 @@ fn wrap_single_line_slow_path<'a>( lines: &mut Vec>, ) { // Span::width (options.initial_indent.width() etc) use the Unicode display - // width, which is what we expect it. + // width, which is what we expect. let initial_width = options.width.saturating_sub(options.initial_indent.width()); let subsequent_width =