diff --git a/.gitignore b/.gitignore index c648f82ea34..746b3100b50 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tools/cockroach* /clickhouse/ /cockroachdb/ smf/nexus/root.json +core diff --git a/Cargo.lock b/Cargo.lock index ff2340871bc..78dbc0e0cd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,6 +419,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" @@ -850,6 +856,32 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "libc", + "mio", + "parking_lot 0.12.1", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + [[package]] name = "crucible-agent-client" version = "0.0.1" @@ -5011,6 +5043,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -6062,6 +6105,19 @@ dependencies = [ "toml", ] +[[package]] +name = "tui" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1" +dependencies = [ + "bitflags", + "cassowary", + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "tungstenite" version = "0.17.3" @@ -6499,6 +6555,20 @@ dependencies = [ "libc", ] +[[package]] +name = "wicket" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "futures", + "slog", + "slog-async", + "slog-term", + "tokio", + "tui", +] + [[package]] name = "widestring" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index f4557e7f9ab..4e3fcc6263c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "oximeter/oximeter-macro-impl", "oximeter-client", "test-utils", + "wicket", ] default-members = [ diff --git a/wicket/Cargo.toml b/wicket/Cargo.toml new file mode 100644 index 00000000000..d468a4bbe62 --- /dev/null +++ b/wicket/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wicket" +version = "0.1.0" +edition = "2021" + +[dependencies] +crossterm = { version = "0.25.0", features = ["event-stream"] } +tui = "0.19.0" +tokio = { version = "1.21.1", features = ["full"] } +anyhow = "1.0.65" +slog = { version = "2.7.0", features = [ "max_level_trace", "release_max_level_debug" ] } +slog-term = "2.9.0" +slog-async = "2.7.0" +futures = "0.3.24" + +[[bin]] +name = "wicket" +doc = false diff --git a/wicket/README.md b/wicket/README.md new file mode 100644 index 00000000000..d9ffb8019e8 --- /dev/null +++ b/wicket/README.md @@ -0,0 +1,124 @@ +# Overview + +Wicket is a TUI built for operator usage at the technician port. It is intended to support a limited set of responsibilities including: + * Rack Initialization + * Boundary service setup + * Disaster Recovery + * Minimal rack update / emergency update + +Wicket is built on top of [crossterm](https://github.com/crossterm-rs/crossterm) +and [tui-rs](https://github.com/fdehau/tui-rs), although the objects +themselves, and the placement of objects on the screen is mostly custom. + +# Navigating + +* `banners` - Files containing "banner-like" output using `#` characters for +glyph drawing +* `src/lib.rs` - Contains the main `Wizard` type which manages the UI, +downstream services, incoming events, and rendering +* `src/mgs.rs` - Source code for interacting with the [Management Gatewway +Service (MGS)](https://github.com/oxidecomputer/management-gateway-service) +* `src/inventory.rs` - Contains rack related components to show in the `Component` `Screen` +* `src/screens` - Each file represents a full terminal size view in the UI. +`Screen`s manage specific events and controls, and render `Widget`s. +* `src/widgets` - These are implementations of a [`tui::Widget`](https://github.com/fdehau/tui-rs/blob/master/src/widgets/mod.rs#L63-L68) +specific to wicket. Widgets are created only to be rendered immediately into a +[`tui::Frame`](https://github.com/fdehau/tui-rs/blob/9806217a6a4c240462bba3b32cb1bc59524f1bc2/src/terminal.rs#L58-L70). +Most widgets contain a reference to state which is mutated by input events, and +defined in the same file as the widget. The state often implements a `Control` +that allows it to be manipulated by the rest of the system in a unified +manner. + +# Design + +The main type of the wicket crate is the `Wizard`. The wizard is run by the `wicket` binary and is in charge of: + * Handling user input (mouse, keyboard, resize) events + * Sending requests to downstream services (MGS + RSS) + * Handling events from downstream services + * Managing the active screen and triggering terminal rendering + +There is a main thread that runs an infinite loop in the `Wizard::mainloop` +method. The loop's job is to receive `Event`s from a single MPSC channel +and update internal state, either directly or by forwarding events to the +currently active screen by calling its `on` method. The active screen +processes events, updates its internal state (possibly including global state +passed in via the `on` method), and returns a list of `Action`s that instructs +the wizard what to do next. Currently there are only two types of `Action`s: + + * `Action::Redraw` - which instructs the Wizard to call the active screen's `draw` method + * `Action::SwitchScreen(ScreenId)` - which instructs the wizard to transition between screens + +It's important to notice that the internal state of the system is only updated +upon event receipt, and that a screen never processes an event that can +mutate state and render in the same method. This makes it very easy to test +the internal state mutations and behavior of a screen. It also means that all +drawing code is effectively stateless and fully immediate. While rendering +`Widget`s relies on the current state of the system, the state of the system +does not change at all during rendering, and so an immutable borrow can be +utilized for this state. This fits well with the `tui-rs` immediate drawing +paradigm where widgets are created right before rendering and passed by value +to the render function, which consumes them. + +Besides the main thread, which runs `mainloop`, there is a separate tokio +runtime which is used to drive communications with MGS and RSS, and to manage +inputs and timers. Requests are driven by MGS and RSS clients and all replies +are handled by these clients in the tokio runtime. Any important information +in these replies is forwarded as an `Event` over a channel to be received +in `mainloop`. All `Event`s, whether respones from downstream services, user +input, or timer ticks, are sent over the same channel in an `Event` enum. This +keeps the `mainloop` simple and provides a total ordering of all events, which +can allow for easier debugging. + +As mentioned above, a timer tick is sent as an `Event::Tick` message over +a channel to the mainloop. Timers currently fire every 25ms, and help drive +any animations. We don't redraw on every timer tick, since it's relatively +expensive to calculate widget positions, and since the screens themselves +return actions when they need to be redrawn. However, the wizard also doesn't +know when a screen animation is ongoing, and so it forwards all ticks to the +currently active screen which returns an `Action::Redraw` if the screen needs +to be redrawn. + +# Screens, Widgets, and Controls + +A `Screen` represents the current visual state of the `Wizard` to the user, and +what inputs are available to the user. Each `Screen` maintains its own internal +state which can be mutated in response to events delivered to it via its `on` +method. The `on` method also provides mutable access to a globl `State` which +is relevant across sceens. As mentioned above, a `Screen::draw` method is +called to render the current screen. + +Screens abstract the terminal display, or tty, which itself can be modeled +as a buffer of characters or a rectangle with a width and height, and x +and y coordinates for the upper left hand corner. This rectangle can be +further divided into rectangles that can be independently styled and drawn. +These rectangles can be manipulated directly, but in the common case this +manipulation is abstracted into a drawable `Widget`. We have implemented +several of our own widgets includng the rack view. Each screen has manual +placement code for these widgets which allows full flexibility and responsive +design. + +Widgets get consumed when drawn to the screen. And the placement code +determines where the widget rectangles are drawn. However, how do we change the +styling of the Widgets, such that we know when a mouse hover is occurring or a +button was clicked? For this purpose, we must track the minimal state required +to render the widgets and implement a `Control`. Controls provide two key +things: access to the rectangle, or `Rect`, that we need in order to draw the +widget on the next render, and a unique ID, that allows screens to keep track +of which control is currently `active` or being hovered over. For example, when +a mouse movement event comes in, we can use rectangle intersection to see if +the mouse is currently over a given Control, and mark it as `hovered'. + + +# What's left? + +There are currently 3 screens implemented: + * Splash screen + * Rack view screen + * Component (Sled, Switch, PSC) view + +Navigation and UI for these screens works well, but there is no backend +functionality implemented. All the inventory and power data shown in the +`Component` screen is fake. We also aren't currently really talking to the MGS +and RSS. Lastly, we don't have a way to take rack updates and install them, or +initialize the rack (including trust quorum). This is a lot of functionality +that will be implemented incrementally. diff --git a/wicket/banners/ox.txt b/wicket/banners/ox.txt new file mode 100644 index 00000000000..cf733c243f2 --- /dev/null +++ b/wicket/banners/ox.txt @@ -0,0 +1,7 @@ + ##### + ## ## +## # ## ## ## +## # ## ## ## +## # ## ### + ## ## ## ## + ##### ## ## diff --git a/wicket/banners/oxide.txt b/wicket/banners/oxide.txt new file mode 100644 index 00000000000..17f5ab4b86b --- /dev/null +++ b/wicket/banners/oxide.txt @@ -0,0 +1,7 @@ + ##### ## ## + ## ## ## +## # ## ## ## ### ### ## #### +## # ## ## ## ## ## ### ## ## +## # ## ### ## ## ## ######## + ## ## ## ## ## ## ### ## + ##### ## ## ###### ### ## #### diff --git a/wicket/src/bin/wicket.rs b/wicket/src/bin/wicket.rs new file mode 100644 index 00000000000..5e536f24e9d --- /dev/null +++ b/wicket/src/bin/wicket.rs @@ -0,0 +1,13 @@ +// 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/. + +use std::error::Error; +use wicket::Wizard; + +fn main() -> Result<(), Box> { + let mut wizard = Wizard::new(); + wizard.run()?; + + Ok(()) +} diff --git a/wicket/src/inventory.rs b/wicket/src/inventory.rs new file mode 100644 index 00000000000..c81c5420426 --- /dev/null +++ b/wicket/src/inventory.rs @@ -0,0 +1,155 @@ +// 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/. + +// Information about all top-level Oxide components (sleds, switches, PSCs) + +use anyhow::anyhow; +use std::collections::BTreeMap; + +/// Inventory is the most recent information about rack composition as +/// received from MGS. +#[derive(Debug, Default)] +pub struct Inventory { + power: BTreeMap, + inventory: BTreeMap, +} + +impl Inventory { + pub fn get_power_state(&self, id: &ComponentId) -> Option<&PowerState> { + self.power.get(id) + } + + pub fn update_power_state( + &mut self, + id: ComponentId, + state: PowerState, + ) -> anyhow::Result<()> { + Self::validate_component_id(id)?; + self.power.insert(id, state); + Ok(()) + } + + pub fn get_inventory(&self, id: &ComponentId) -> Option<&Component> { + self.inventory.get(id) + } + + pub fn update_inventory( + &mut self, + id: ComponentId, + component: Component, + ) -> anyhow::Result<()> { + Self::validate_component_id(id)?; + self.inventory.insert(id, component); + Ok(()) + } + + fn validate_component_id(id: ComponentId) -> anyhow::Result<()> { + match id { + ComponentId::Sled(i) if i > 31 => { + Err(anyhow!("Invalid sled slot: {}", i)) + } + ComponentId::Switch(i) if i > 1 => { + Err(anyhow!("Invalid switch slot: {}", i)) + } + ComponentId::Psc(i) if i > 1 => { + Err(anyhow!("Invalid power shelf slot: {}", i)) + } + _ => Ok(()), + } + } +} + +#[derive(Debug)] +pub struct FakeSled { + // 0-31 + pub slot: u8, + pub serial_number: String, + pub part_number: String, + pub sp_version: String, + pub rot_version: String, + pub host_os_version: String, + pub control_plane_version: Option, +} + +#[derive(Debug)] +pub struct FakeSwitch { + // Top is 0, bottom is 1 + pub slot: u8, + pub serial_number: String, + pub part_number: String, + pub sp_version: String, + pub rot_version: String, +} + +#[derive(Debug)] +pub struct FakePsc { + // Top is 0 power shelf, 1 is bottom + pub slot: u8, + pub serial_number: String, + pub part_number: String, + pub sp_version: String, + pub rot_version: String, +} + +/// TODO: Use real inventory received from MGS +#[derive(Debug)] +pub enum Component { + Sled(FakeSled), + Switch(FakeSwitch), + Psc(FakePsc), +} + +impl Component { + pub fn name(&self) -> String { + match self { + Component::Sled(s) => format!("sled {}", s.slot), + Component::Switch(s) => format!("switch {}", s.slot), + Component::Psc(p) => format!("psc {}", p.slot), + } + } +} + +// The component type and its slot. +#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)] +pub enum ComponentId { + Sled(u8), + Switch(u8), + Psc(u8), +} + +impl ComponentId { + pub fn name(&self) -> String { + match self { + ComponentId::Sled(i) => format!("sled {}", i), + ComponentId::Switch(i) => format!("switch {}", i), + ComponentId::Psc(i) => format!("psc {}", i), + } + } +} + +#[derive(Debug)] +pub enum PowerState { + /// Working + A0, + /// Sojourning + A1, + /// Quiescent + A2, + /// Commanded Off + A3, + /// Mechanical Off + A4, +} + +impl PowerState { + pub fn description(&self) -> &'static str { + match self { + PowerState::A0 => "working", + PowerState::A1 => "sojourning", + PowerState::A2 => "quiescent", + PowerState::A3 => "commanded off", + PowerState::A4 => "mechanical off (unplugged)", + } + } +} diff --git a/wicket/src/lib.rs b/wicket/src/lib.rs new file mode 100644 index 00000000000..3c10f7e790e --- /dev/null +++ b/wicket/src/lib.rs @@ -0,0 +1,410 @@ +// 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/. + +//! The library that is used via the technician port to initialize a rack +//! and perform disaster recovery. +//! +//! This interface is a text user interface (TUI) based wizard +//! that will guide the user through the steps the need to take +//! in an intuitive manner. + +use crossterm::event::Event as TermEvent; +use crossterm::event::EventStream; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{MouseEvent, MouseEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, + LeaveAlternateScreen, +}; +use futures::StreamExt; +use slog::{error, info, Drain}; +use std::io::{stdout, Stdout}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use tokio::time::{interval, Duration}; +use tui::backend::CrosstermBackend; +use tui::layout::Rect; +use tui::Terminal; + +pub(crate) mod inventory; +mod mgs; +mod screens; +mod widgets; + +use inventory::{Component, ComponentId, Inventory, PowerState}; +use mgs::{MgsHandle, MgsManager}; +use screens::{Height, ScreenId, Screens}; +use widgets::RackState; + +pub const MARGIN: Height = Height(5); + +// We can avoid a bunch of unnecessary type parameters by picking them ahead of time. +pub type Term = Terminal>; +pub type Frame<'a> = tui::Frame<'a, CrosstermBackend>; + +/// The core type of this library is the `Wizard`. +/// +/// A `Wizard` manages a set of [`Screen`]s, where each screen represents a +/// specific step in the user process. Each [`Screen`] is drawable, and the +/// active screen is rendered on every tick. The [`Wizard`] manages which +/// screen is active, issues the rendering operation to the terminal, and +/// communicates with other threads and async tasks to receive user input +/// and drive backend services. +pub struct Wizard { + // The currently active screen + active_screen: ScreenId, + + // All the screens managed by the [`Wizard`] + screens: Screens, + + // The [`Wizard`] is purely single threaded. Every interaction with the + // outside world is via channels. All receiving from the outside world + // comes in via an `Event` over a single channel. + // + // Doing this allows us to record and replay all received events, which + // will deterministically draw the output of the UI, as long as we disable + // any output to downstream services. + // + // This effectively acts as a way to mock real responses from servers + // without ever having to run those servers or even send the requests that + // triggered the incoming events! + // + // Note that for resize events or other terminal specific events we'll + // likely have to "output" them to fake the same interaction. + events_rx: Receiver, + + // We save a copy here so we can hand it out to event producers + events_tx: Sender, + + // The internal state of the Wizard + // This contains all updatable data + state: State, + + // A mechanism for interacting with MGS + #[allow(unused)] + mgs: MgsHandle, + + // When the Wizard is run, this will be extracted and moved + // into a tokio task. + mgs_manager: Option, + + // The terminal we are rendering to + terminal: Term, + + // Our friendly neighborhood logger + log: slog::Logger, + + // The tokio runtime for everything outside the main thread + tokio_rt: tokio::runtime::Runtime, +} + +#[allow(clippy::new_without_default)] +impl Wizard { + pub fn new() -> Wizard { + let log = Self::setup_log("/tmp/wicket.log").unwrap(); + let screens = Screens::new(&log); + let (events_tx, events_rx) = channel(); + let state = State::new(); + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend).unwrap(); + let log = Self::setup_log("/tmp/wicket.log").unwrap(); + let tokio_rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let (mgs, mgs_manager) = MgsManager::new(&log, events_tx.clone()); + Wizard { + screens, + active_screen: ScreenId::Splash, + events_rx, + events_tx, + state, + mgs, + mgs_manager: Some(mgs_manager), + terminal, + log, + tokio_rt, + } + } + + pub fn setup_log(path: &str) -> anyhow::Result { + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + + let decorator = slog_term::PlainDecorator::new(file); + let drain = slog_term::FullFormat::new(decorator).build().fuse(); + let drain = slog_async::Async::new(drain).build().fuse(); + + Ok(slog::Logger::root(drain, slog::o!())) + } + + pub fn run(&mut self) -> anyhow::Result<()> { + self.start_tokio_runtime(); + enable_raw_mode()?; + execute!( + self.terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + )?; + self.mainloop()?; + disable_raw_mode()?; + execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + Ok(()) + } + + fn mainloop(&mut self) -> anyhow::Result<()> { + // Size the rack for the initial draw + self.state + .rack_state + .resize(&self.terminal.get_frame().size(), &MARGIN); + + // Draw the initial screen + let screen = self.screens.get_mut(self.active_screen); + screen.draw(&self.state, &mut self.terminal)?; + + loop { + let screen = self.screens.get_mut(self.active_screen); + // unwrap is safe because we always hold onto a Sender + let event = self.events_rx.recv().unwrap(); + match event { + Event::Tick => { + let actions = screen.on(&mut self.state, ScreenEvent::Tick); + self.handle_actions(actions)?; + } + Event::Term(TermEvent::Key(key_event)) => { + if is_control_c(&key_event) { + info!(self.log, "CTRL-C Pressed. Exiting."); + break; + } + let actions = screen.on( + &mut self.state, + ScreenEvent::Term(TermEvent::Key(key_event)), + ); + self.handle_actions(actions)?; + } + Event::Term(TermEvent::Resize(width, height)) => { + let rect = Rect { x: 0, y: 0, width, height }; + self.state.rack_state.resize(&rect, &MARGIN); + screen.draw(&self.state, &mut self.terminal)?; + } + Event::Term(TermEvent::Mouse(mouse_event)) => { + self.state.mouse = + Point { x: mouse_event.column, y: mouse_event.row }; + let actions = screen.on( + &mut self.state, + ScreenEvent::Term(TermEvent::Mouse(mouse_event)), + ); + self.handle_actions(actions)?; + } + Event::Power(component_id, power_state) => { + if let Err(e) = self + .state + .inventory + .update_power_state(component_id, power_state) + { + error!( + self.log, + "Failed to update power state for {}: {e}", + component_id.name() + ); + } + } + Event::Inventory(component_id, component) => { + if let Err(e) = self + .state + .inventory + .update_inventory(component_id, component) + { + error!( + self.log, + "Failed to update inventory for {}: {e}", + component_id.name() + ); + } + } + _ => info!(self.log, "{:?}", event), + } + } + Ok(()) + } + + fn handle_actions(&mut self, actions: Vec) -> anyhow::Result<()> { + for action in actions { + match action { + Action::Redraw => { + let screen = self.screens.get_mut(self.active_screen); + screen.draw(&self.state, &mut self.terminal)?; + } + Action::SwitchScreen(id) => { + self.active_screen = id; + let screen = self.screens.get_mut(id); + // Simulate a mouse movement for the current position + // because the mouse may be in a different position when transitioning + // between screens. + let mouse_event = MouseEvent { + kind: MouseEventKind::Moved, + column: self.state.mouse.x, + row: self.state.mouse.y, + modifiers: KeyModifiers::NONE, + }; + let event = + ScreenEvent::Term(TermEvent::Mouse(mouse_event)); + // We ignore actions, as they can only be draw actions, and + // we are about to draw. + let _ = screen.on(&mut self.state, event); + screen.draw(&self.state, &mut self.terminal)?; + } + } + } + Ok(()) + } + + fn start_tokio_runtime(&mut self) { + let events_tx = self.events_tx.clone(); + let log = self.log.clone(); + let mgs_manager = self.mgs_manager.take().unwrap(); + self.tokio_rt.block_on(async { + run_event_listener(log.clone(), events_tx).await; + tokio::spawn(async move { + mgs_manager.run().await; + }) + .await + .unwrap(); + }); + } +} + +fn is_control_c(key_event: &KeyEvent) -> bool { + key_event.code == KeyCode::Char('c') + && key_event.modifiers == KeyModifiers::CONTROL +} + +/// Listen for terminal related events +async fn run_event_listener(log: slog::Logger, events_tx: Sender) { + info!(log, "Starting event listener"); + tokio::spawn(async move { + let mut events = EventStream::new(); + let mut ticker = interval(Duration::from_millis(30)); + loop { + tokio::select! { + _ = ticker.tick() => { + if events_tx.send(Event::Tick).is_err() { + info!(log, "Event listener completed"); + // The receiver was dropped. Program is ending. + return; + } + } + event = events.next() => { + let event = match event { + None => { + error!(log, "Event stream completed. Shutting down."); + return; + } + Some(Ok(event)) => event, + Some(Err(e)) => { + // TODO: Issue a shutdown + error!(log, "Failed to receive event: {:?}", e); + return; + } + }; + if events_tx.send(Event::Term(event)).is_err() { + info!(log, "Event listener completed"); + // The receiver was dropped. Program is ending. + return; + } + + } + } + } + }); +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Point { + pub x: u16, + pub y: u16, +} + +/// The data state of the Wizard +/// +/// Data is not tied to any specific screen and is updated upon event receipt. +#[derive(Debug)] +pub struct State { + pub inventory: Inventory, + pub rack_state: RackState, + pub mouse: Point, +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +impl State { + pub fn new() -> State { + State { + inventory: Inventory::default(), + rack_state: RackState::new(), + mouse: Point::default(), + } + } +} + +/// Send requests to RSS +/// +/// Replies come in as [`Event`]s +pub struct RssManager {} + +/// An event that will update state in the wizard +/// +/// This can be a keypress, mouse event, or response from a downstream service. +#[derive(Debug)] +pub enum Event { + /// An input event from the terminal + Term(TermEvent), + + /// An Inventory Update Event + /// + /// TODO: This should be real information returned from MGS + Inventory(ComponentId, Component), + + /// PowerState changes + Power(ComponentId, PowerState), + + /// The tick of a Timer + /// This can be used to draw a frame to the terminal + Tick, + //... TODO: Replies from MGS & RSS +} + +/// An action for the system to take. +/// +/// This can be something like a screen transition or calling a downstream +/// service. Screens never take actions directly, but they are the only ones +/// that know what visual content an input such as a key press or mouse event +/// is meant for and what action should be taken in that case. +pub enum Action { + Redraw, + SwitchScreen(ScreenId), +} + +/// Events sent to a screen +/// +/// These are a subset of [`Event`] +pub enum ScreenEvent { + /// An input event from the terminal + Term(crossterm::event::Event), + + /// The tick of a timer + Tick, +} diff --git a/wicket/src/mgs.rs b/wicket/src/mgs.rs new file mode 100644 index 00000000000..9fc7be7d82e --- /dev/null +++ b/wicket/src/mgs.rs @@ -0,0 +1,144 @@ +// 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/. + +//! Interaction with MGS + +use slog::{o, Logger}; +use std::sync::mpsc::Sender; + +use crate::inventory::{ + Component, ComponentId, FakePsc, FakeSled, FakeSwitch, PowerState, +}; +use crate::Event; + +// Assume that these requests are periodic on the order of seconds or the +// result of human interaction. In either case, this buffer should be plenty +// large. +const CHANNEL_CAPACITY: usize = 1000; + +pub enum MgsRequest {} + +#[allow(unused)] +pub struct MgsHandle { + tx: tokio::sync::mpsc::Sender, +} + +/// Send requests to MGS +/// +/// Forward replies to the [`Wizard`] as [`Event`]s +#[allow(unused)] +pub struct MgsManager { + log: Logger, + rx: tokio::sync::mpsc::Receiver, + wizard_tx: Sender, +} + +impl MgsManager { + pub fn new( + log: &Logger, + wizard_tx: Sender, + ) -> (MgsHandle, MgsManager) { + let log = log.new(o!("component" => "MgsManager")); + let (tx, rx) = tokio::sync::mpsc::channel(CHANNEL_CAPACITY); + + let handle = MgsHandle { tx }; + let manager = MgsManager { log, rx, wizard_tx }; + + (handle, manager) + } + + /// Manage interactions with local MGS + /// + /// * Send requests to MGS + /// * Receive responses / errors + /// * Translate any responses/errors into [`Event`]s + /// * that can be utilized by the UI. + /// + /// TODO: Uh, um, make this not completely fake + pub async fn run(self) { + self.announce_fake_power_states(); + self.announce_fake_inventory(); + } + + pub fn announce_fake_power_states(&self) { + for i in 0..32 { + let state = { + match i % 4 { + 0 => PowerState::A0, + 1 => PowerState::A2, + 2 => PowerState::A3, + 3 => PowerState::A4, + _ => unreachable!(), + } + }; + self.wizard_tx + .send(Event::Power(ComponentId::Sled(i), state)) + .unwrap(); + } + self.wizard_tx + .send(Event::Power(ComponentId::Switch(0), PowerState::A0)) + .unwrap(); + self.wizard_tx + .send(Event::Power(ComponentId::Switch(1), PowerState::A0)) + .unwrap(); + self.wizard_tx + .send(Event::Power(ComponentId::Psc(0), PowerState::A0)) + .unwrap(); + self.wizard_tx + .send(Event::Power(ComponentId::Psc(1), PowerState::A4)) + .unwrap(); + } + + // Send an Inventory message to the Wizard for each component + // in state A2 or greater. + // TODO: Replace this + fn announce_fake_inventory(&self) { + for i in 0..32u8 { + let state = i % 4; + if state == 0 || state == 1 { + self.wizard_tx + .send(Event::Inventory( + ComponentId::Sled(i), + Component::Sled(FakeSled { + slot: i, + serial_number: format!("sled-{}", i), + part_number: "Gimlet v1".into(), + sp_version: "1.0".into(), + rot_version: "1.0".into(), + host_os_version: "1.0".into(), + control_plane_version: None, + }), + )) + .unwrap(); + } + } + for i in 0..2u8 { + self.wizard_tx + .send(Event::Inventory( + ComponentId::Switch(i), + Component::Switch(FakeSwitch { + slot: i, + serial_number: format!("switch-{}", i), + part_number: "Sidecar v1".into(), + sp_version: "1.0".into(), + rot_version: "1.0".into(), + }), + )) + .unwrap(); + } + + self.wizard_tx + .send(Event::Inventory( + ComponentId::Psc(0), + Component::Psc(FakePsc { + slot: 0u8, + serial_number: format!("PSC-{}", 0), + part_number: "Sidecar v1".into(), + sp_version: "1.0".into(), + rot_version: "1.0".into(), + }), + )) + .unwrap(); + } +} diff --git a/wicket/src/screens/component.rs b/wicket/src/screens/component.rs new file mode 100644 index 00000000000..a4247837c5f --- /dev/null +++ b/wicket/src/screens/component.rs @@ -0,0 +1,338 @@ +// 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/. + +//! Inventory and control of individual rack components: sled,switch,psc + +use super::colors::*; +use super::Screen; +use crate::widgets::Control; +use crate::widgets::ControlId; +use crate::widgets::HelpMenuState; +use crate::widgets::{HelpButton, HelpButtonState, HelpMenu}; +use crate::widgets::{ScreenButton, ScreenButtonState}; +use crate::Action; +use crate::Frame; +use crate::ScreenEvent; +use crate::ScreenId; +use crate::State; +use crossterm::event::Event as TermEvent; +use crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use tui::layout::Alignment; +use tui::style::Modifier; +use tui::style::{Color, Style}; +use tui::text::{Span, Spans, Text}; +use tui::widgets::Block; +use tui::widgets::Paragraph; + +pub struct ComponentScreen { + hovered: Option, + help_data: Vec<(&'static str, &'static str)>, + help_button_state: HelpButtonState, + help_menu_state: HelpMenuState, + rack_screen_button_state: ScreenButtonState, +} + +impl ComponentScreen { + pub fn new() -> ComponentScreen { + let help_data = vec![ + ("", "Cycle forward through components"), + ("-", "Cycle backwards through components"), + ("", "Go back to the rack screen"), + ("", "Exit the program"), + ]; + ComponentScreen { + hovered: None, + help_data, + help_button_state: HelpButtonState::new(1, 0), + help_menu_state: HelpMenuState::default(), + rack_screen_button_state: ScreenButtonState::new( + ScreenId::Rack, + // This get's reset on every draw + u16::MAX, + 0, + ), + } + } + + fn draw_background(&self, f: &mut Frame) { + let style = Style::default().fg(OX_GREEN_DARK).bg(Color::Black); + let block = Block::default().style(style); + f.render_widget(block, f.size()); + } + + fn draw_status_bar(&self, f: &mut Frame, state: &State) { + let mut rect = f.size(); + rect.height = 5; + + let style = Style::default().bg(OX_GREEN_DARK).fg(OX_GRAY); + let selected_style = Style::default().fg(OX_GREEN_LIGHT); + let help_menu_style = + Style::default().fg(OX_OFF_WHITE).bg(OX_GREEN_DARK); + let help_menu_command_style = + Style::default().fg(OX_GREEN_LIGHT).bg(OX_GREEN_DARK); + let button_style = Style::default().fg(OX_OFF_WHITE).bg(OX_GREEN_DARK); + let hovered_style = Style::default().fg(OX_PINK).bg(OX_GREEN_DARK); + + let status_bar_block = Block::default().style(style); + f.render_widget(status_bar_block, rect); + + let current = state.rack_state.get_current_component_id(); + + // Draw the components list + // TODO: Some sliding style animation? + let title = Spans::from(vec![ + Span::styled( + state.rack_state.get_next_component_id().name(), + style, + ), + Span::raw(" "), + Span::styled(current.name(), selected_style), + Span::raw(" "), + Span::styled( + state.rack_state.get_next_component_id().name(), + style, + ), + ]); + + let mut rect = f.size(); + rect.height = 1; + rect.y = 1; + let title_block = Block::default() + .style(style) + .title(title) + .title_alignment(Alignment::Center); + f.render_widget(title_block, rect); + + // Draw the power state + let title = match state.inventory.get_power_state(¤t) { + Some(s) => { + format!( + "⌁ Power State: {}", + s.description().to_ascii_uppercase() + ) + } + None => "⌁ Power State: UNKNOWN".to_string(), + }; + + let mut rect = f.size(); + rect.height = 1; + rect.y = 3; + let power_state_block = Block::default() + .style(selected_style) + .title(title) + .title_alignment(Alignment::Center); + f.render_widget(power_state_block, rect); + + // Draw the help button if the help menu is closed, otherwise draw the + // help menu + if !self.help_menu_state.is_closed() { + let menu = HelpMenu { + help: &self.help_data, + style: help_menu_style, + command_style: help_menu_command_style, + // Unwrap is safe because we check that the menu is open (and + // thus has an AnimationState). + state: self.help_menu_state.get_animation_state().unwrap(), + }; + f.render_widget(menu, f.size()); + } else { + let border_style = + if self.hovered == Some(self.help_button_state.id()) { + hovered_style + } else { + button_style + }; + let button = HelpButton::new( + &self.help_button_state, + button_style, + border_style, + ); + + f.render_widget(button, f.size()); + } + + // Draw the RackSreenButton + let border_style = + if self.hovered == Some(self.rack_screen_button_state.id()) { + hovered_style + } else { + button_style + }; + let button = ScreenButton::new( + &self.rack_screen_button_state, + button_style, + border_style, + ); + f.render_widget(button, f.size()); + } + + fn draw_inventory(&self, f: &mut Frame, state: &State) { + // Draw the header + let selected_style = Style::default().fg(OX_GREEN_LIGHT); + let inventory_style = Style::default().fg(OX_YELLOW_DIM); + + let mut header_style = selected_style; + header_style = + header_style.add_modifier(Modifier::UNDERLINED | Modifier::BOLD); + + let text = Text::styled("INVENTORY\n\n", header_style); + let mut rect = f.size(); + rect.y = 6; + rect.height -= 6; + let center = (rect.width - text.width() as u16) / 2; + rect.x += center; + rect.width -= center; + let header = Paragraph::new(text); + f.render_widget(header, rect); + + // Draw the contents + let text = match state + .inventory + .get_inventory(&state.rack_state.get_current_component_id()) + { + Some(inventory) => { + Text::styled(format!("{:#?}", inventory), inventory_style) + } + None => Text::styled("UNKNOWN", inventory_style), + }; + + let mut rect = f.size(); + rect.y = 9; + rect.height -= 9; + + let center = (rect.width - text.width() as u16) / 2; + rect.x += center; + rect.width -= center; + + let inventory = Paragraph::new(text); + f.render_widget(inventory, rect); + } + + fn handle_key_event( + &mut self, + state: &mut State, + event: KeyEvent, + ) -> Vec { + match event.code { + KeyCode::Tab => { + state.rack_state.inc_tab_index(); + } + KeyCode::BackTab => { + state.rack_state.dec_tab_index(); + } + KeyCode::Char('r') => { + if event.modifiers.contains(KeyModifiers::CONTROL) { + return vec![Action::SwitchScreen(ScreenId::Rack)]; + } + } + KeyCode::Char('h') => { + if event.modifiers.contains(KeyModifiers::CONTROL) { + self.help_menu_state.toggle(); + } + } + _ => (), + } + vec![Action::Redraw] + } + + fn handle_mouse_event( + &mut self, + state: &mut State, + event: MouseEvent, + ) -> Vec { + match event.kind { + MouseEventKind::Moved => { + self.set_hover_state(state, event.column, event.row) + } + MouseEventKind::Down(MouseButton::Left) => { + self.handle_mouse_click(state) + } + _ => vec![], + } + } + + fn handle_mouse_click(&mut self, _: &mut State) -> Vec { + match self.hovered { + Some(control_id) if control_id == self.help_button_state.id() => { + self.help_menu_state.open(); + vec![] + } + Some(control_id) + if control_id == self.rack_screen_button_state.id() => + { + vec![Action::SwitchScreen(ScreenId::Rack)] + } + _ => vec![], + } + } + + fn set_hover_state( + &mut self, + _state: &mut State, + x: u16, + y: u16, + ) -> Vec { + let current_id = self.find_intersection(x, y); + if current_id == self.hovered { + // No change + vec![] + } else { + self.hovered = current_id; + vec![Action::Redraw] + } + } + + // Return if the coordinates interesct a given control. + // This assumes disjoint control rectangles. + fn find_intersection(&self, x: u16, y: u16) -> Option { + if self.help_button_state.intersects_point(x, y) { + Some(self.help_button_state.id()) + } else if self.rack_screen_button_state.intersects_point(x, y) { + Some(self.rack_screen_button_state.id()) + } else { + None + } + } +} + +impl Screen for ComponentScreen { + fn draw( + &mut self, + state: &State, + terminal: &mut crate::Term, + ) -> anyhow::Result<()> { + terminal.draw(|f| { + self.rack_screen_button_state.rect.x = + f.size().width - ScreenButtonState::width(); + self.draw_background(f); + self.draw_status_bar(f, state); + self.draw_inventory(f, state); + })?; + Ok(()) + } + + fn on(&mut self, state: &mut State, event: ScreenEvent) -> Vec { + match event { + ScreenEvent::Term(TermEvent::Key(key_event)) => { + self.handle_key_event(state, key_event) + } + ScreenEvent::Term(TermEvent::Mouse(mouse_event)) => { + self.handle_mouse_event(state, mouse_event) + } + ScreenEvent::Tick => { + if !self.help_menu_state.is_closed() { + self.help_menu_state.step(); + vec![Action::Redraw] + } else { + vec![] + } + } + _ => vec![], + } + } +} diff --git a/wicket/src/screens/mod.rs b/wicket/src/screens/mod.rs new file mode 100644 index 00000000000..3325f40870f --- /dev/null +++ b/wicket/src/screens/mod.rs @@ -0,0 +1,198 @@ +// 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/. + +mod component; +mod rack; +mod splash; + +use crate::Action; +use crate::ScreenEvent; +use crate::State; +use crate::Term; +use slog::Logger; + +use component::ComponentScreen; +use rack::RackScreen; +use splash::SplashScreen; + +/// An identifier for a specific [`Screen`] in the [`Wizard`] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum ScreenId { + Splash, + Rack, + Component, + Update, +} + +impl ScreenId { + pub fn name(&self) -> &'static str { + match self { + ScreenId::Splash => "splash", + ScreenId::Rack => "rack", + ScreenId::Component => "component", + ScreenId::Update => "update", + } + } + + /// Width of the maximum string length of the name + pub fn width() -> u16 { + 9 + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Height(pub u16); + +#[derive(Debug, Clone, Copy)] +pub struct Width(pub u16); + +/// Ensure that a u16 is an even number by adding 1 if necessary. +pub fn make_even(val: u16) -> u16 { + if val % 2 == 0 { + val + } else { + val + 1 + } +} + +pub trait Screen { + /// Draw the [`Screen`] + fn draw( + &mut self, + state: &State, + terminal: &mut Term, + ) -> anyhow::Result<()>; + + /// Handle a [`ScreenEvent`] to update internal display state and output + /// any necessary actions for the system to take. + fn on(&mut self, state: &mut State, event: ScreenEvent) -> Vec; +} + +/// All [`Screen`]s for wicket +pub struct Screens { + splash: SplashScreen, + rack: RackScreen, + component: ComponentScreen, +} + +impl Screens { + pub fn new(log: &Logger) -> Screens { + Screens { + splash: SplashScreen::new(), + rack: RackScreen::new(log), + component: ComponentScreen::new(), + } + } + + pub fn get_mut(&mut self, id: ScreenId) -> &mut dyn Screen { + match id { + ScreenId::Splash => &mut self.splash, + ScreenId::Rack => &mut self.rack, + ScreenId::Component => &mut self.component, + _ => unimplemented!(), + } + } +} + +// A mechanism for keeping track of user `tab` presses inside a screen. +// The index wraps around after `max` and `0`. +// +// Each screen maintains a mapping of TabIndex to the appropriate screen +// objects/widgets. +#[derive(Debug, Clone, Copy, PartialOrd, PartialEq, Eq, Ord)] +pub struct TabIndex { + current: Option, + max: u16, +} + +impl TabIndex { + // Create an unset TabIndex + pub fn new_unset(max: u16) -> TabIndex { + assert!(max < u16::MAX); + TabIndex { current: None, max } + } + + // Create a TabIndex with a set value + pub fn new(max: u16, val: u16) -> TabIndex { + assert!(max < u16::MAX); + assert!(val <= max); + TabIndex { current: Some(val), max } + } + + // Unset the current index + pub fn clear(&mut self) { + self.current = None; + } + + // Return true if current tab index is set, false otherwise + pub fn is_set(&self) -> bool { + self.current.is_some() + } + + // Set the current tab index + pub fn set(&mut self, i: u16) { + assert!(i <= self.max); + self.current = Some(i); + } + + // Get the next tab index + pub fn next(&self) -> TabIndex { + self.current.as_ref().map_or_else( + || *self, + |&i| { + let current = if i == self.max { 0 } else { i + 1 }; + TabIndex { current: Some(current), max: self.max } + }, + ) + } + + // Get the previous tab index + pub fn prev(&self) -> TabIndex { + self.current.as_ref().map_or_else( + || *self, + |&i| { + let current = if i == 0 { self.max } else { i - 1 }; + TabIndex { current: Some(current), max: self.max } + }, + ) + } + + // Increment the current value + pub fn inc(&mut self) { + let cur = self.current.get_or_insert(self.max); + if *cur == self.max { + *cur = 0; + } else { + *cur += 1; + } + } + + // Decrement the current value + pub fn dec(&mut self) { + let cur = self.current.get_or_insert(0); + if *cur == 0 { + *cur = self.max; + } else { + *cur -= 1; + } + } +} + +/// Oxide specific colors from the website +/// Thanks for the idea JMC! +#[allow(unused)] +pub mod colors { + use tui::style::Color; + pub const OX_YELLOW: Color = Color::Rgb(0xF5, 0xCF, 0x65); + pub const OX_OFF_WHITE: Color = Color::Rgb(0xE0, 0xE0, 0xE0); + pub const OX_RED: Color = Color::Rgb(255, 145, 173); + pub const OX_GREEN_LIGHT: Color = Color::Rgb(0x48, 0xD5, 0x97); + pub const OX_GREEN_DARK: Color = Color::Rgb(0x11, 0x27, 0x25); + pub const OX_GREEN_DARKEST: Color = Color::Rgb(0x0B, 0x14, 0x18); + pub const OX_GRAY: Color = Color::Rgb(0x9C, 0x9F, 0xA0); + pub const OX_GRAY_DARK: Color = Color::Rgb(0x62, 0x66, 0x68); + pub const OX_WHITE: Color = Color::Rgb(0xE7, 0xE7, 0xE8); + pub const OX_PINK: Color = Color::Rgb(0xE6, 0x68, 0x86); + pub const OX_YELLOW_DIM: Color = Color::Rgb(0xAE, 0x96, 0x4E); +} diff --git a/wicket/src/screens/rack.rs b/wicket/src/screens/rack.rs new file mode 100644 index 00000000000..69377f51176 --- /dev/null +++ b/wicket/src/screens/rack.rs @@ -0,0 +1,318 @@ +// 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/. + +//! The Rack presentation [`Screen`] + +use super::colors::*; +use super::Screen; +use super::ScreenId; +use super::{Height, Width}; +use crate::widgets::Control; +use crate::widgets::ControlId; +use crate::widgets::HelpMenuState; +use crate::widgets::{Banner, HelpButton, HelpButtonState, HelpMenu, Rack}; +use crate::Action; +use crate::Frame; +use crate::ScreenEvent; +use crate::State; +use crossterm::event::Event as TermEvent; +use crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use slog::Logger; +use tui::layout::Alignment; +use tui::style::{Color, Style}; +use tui::widgets::{Block, Borders}; + +/// Show the rack view +pub struct RackScreen { + #[allow(unused)] + log: Logger, + watermark: &'static str, + hovered: Option, + help_data: Vec<(&'static str, &'static str)>, + help_button_state: HelpButtonState, + help_menu_state: HelpMenuState, +} + +impl RackScreen { + pub fn new(log: &Logger) -> RackScreen { + let help_data = vec![ + ("", "Cycle forward through components"), + ("-", "Cycle backwards through components"), + (" | left mouse click", "Select hovered object"), + ("", "Reset the TabIndex of the Rack"), + ("", "Exit the program"), + ]; + + RackScreen { + log: log.clone(), + watermark: include_str!("../../banners/oxide.txt"), + hovered: None, + help_data, + help_button_state: HelpButtonState::new(1, 0), + help_menu_state: HelpMenuState::default(), + } + } + + fn draw_background(&self, f: &mut Frame) { + let style = Style::default().fg(OX_GREEN_DARK).bg(OX_GRAY); + let block = Block::default().style(style).borders(Borders::NONE); + f.render_widget(block, f.size()); + } + + fn draw_menubar(&self, f: &mut Frame) { + let style = Style::default().fg(OX_GREEN_DARK).bg(OX_GRAY); + let button_style = Style::default().fg(OX_OFF_WHITE).bg(OX_GRAY_DARK); + let hovered_style = Style::default().fg(OX_PINK).bg(OX_GRAY_DARK); + let help_menu_style = + Style::default().fg(OX_OFF_WHITE).bg(OX_GREEN_DARK); + let help_menu_command_style = + Style::default().fg(OX_GREEN_LIGHT).bg(OX_GREEN_DARK); + + // Draw the title + let mut rect = f.size(); + let title = "Oxide Rack"; + rect.height = 1; + rect.y = 1; + let title_block = Block::default() + .style(style) + .title(title) + .title_alignment(Alignment::Center); + f.render_widget(title_block, rect); + + // Draw the help button if the help menu is closed, otherwise draw the + // help menu + if !self.help_menu_state.is_closed() { + let menu = HelpMenu { + help: &self.help_data, + style: help_menu_style, + command_style: help_menu_command_style, + state: self.help_menu_state.get_animation_state().unwrap(), + }; + f.render_widget(menu, f.size()); + } else { + let border_style = + if self.hovered == Some(self.help_button_state.id()) { + hovered_style + } else { + button_style + }; + let button = HelpButton::new( + &self.help_button_state, + button_style, + border_style, + ); + + f.render_widget(button, f.size()); + } + } + + fn draw_watermark(&self, state: &State, f: &mut Frame) -> (Height, Width) { + let style = Style::default().fg(OX_GRAY_DARK).bg(OX_GRAY); + let banner = Banner::new(self.watermark).style(style); + let height = banner.height(); + let width = banner.width(); + let mut rect = f.size(); + + // Only draw the banner if there is enough horizontal whitespace to + // make it look good. + if state.rack_state.rect.width * 3 + width > rect.width { + return (Height(0), Width(0)); + } + + rect.x = rect.width - width - 1; + rect.y = rect.height - height - 1; + rect.width = width; + rect.height = height; + + f.render_widget(banner, rect); + + (Height(height), Width(width)) + } + + /// Draw the rack in the center of the screen. + /// Scale it to look nice. + fn draw_rack(&mut self, state: &State, f: &mut Frame) { + let rack = Rack { + state: &state.rack_state, + switch_style: Style::default().bg(OX_GRAY_DARK).fg(OX_WHITE), + power_shelf_style: Style::default().bg(OX_GRAY).fg(OX_OFF_WHITE), + sled_style: Style::default().bg(OX_GREEN_LIGHT).fg(Color::Black), + sled_selected_style: Style::default() + .fg(Color::Black) + .bg(OX_GRAY_DARK), + + border_style: Style::default().fg(OX_GRAY).bg(Color::Black), + border_selected_style: Style::default() + .fg(OX_YELLOW) + .bg(OX_GRAY_DARK), + + border_hover_style: Style::default().fg(OX_PINK).bg(OX_GRAY_DARK), + switch_selected_style: Style::default().bg(OX_GRAY_DARK), + power_shelf_selected_style: Style::default().bg(OX_GRAY), + }; + + let area = state.rack_state.rect; + f.render_widget(rack, area); + } + + fn handle_key_event( + &mut self, + state: &mut State, + event: KeyEvent, + ) -> Vec { + match event.code { + KeyCode::Tab => { + state.rack_state.inc_tab_index(); + } + KeyCode::BackTab => { + state.rack_state.dec_tab_index(); + } + KeyCode::Esc => { + state.rack_state.clear_tab_index(); + } + KeyCode::Enter => { + if state.rack_state.tab_index.is_set() { + return vec![Action::SwitchScreen(ScreenId::Component)]; + } + } + KeyCode::Char('h') => { + if event.modifiers.contains(KeyModifiers::CONTROL) { + self.help_menu_state.toggle(); + } + } + KeyCode::Char('k') => { + if event.modifiers.contains(KeyModifiers::CONTROL) { + state.rack_state.toggle_knight_rider_mode(); + } + } + _ => (), + } + vec![Action::Redraw] + } + + fn handle_mouse_event( + &mut self, + state: &mut State, + event: MouseEvent, + ) -> Vec { + match event.kind { + MouseEventKind::Moved => { + self.set_hover_state(state, event.column, event.row) + } + MouseEventKind::Down(MouseButton::Left) => { + self.handle_mouse_click(state) + } + _ => vec![], + } + } + + fn handle_mouse_click(&mut self, state: &mut State) -> Vec { + // Set the tab index to the hovered component Id if there is one. + // Remove the old tab_index, and make it match the clicked one + match self.hovered { + Some(control_id) if control_id == self.help_button_state.id() => { + self.help_menu_state.open(); + vec![] + } + Some(control_id) if control_id == state.rack_state.id() => { + state.rack_state.set_tab_from_hovered(); + vec![Action::SwitchScreen(ScreenId::Component)] + } + _ => vec![], + } + } + + // Discover which rect the mouse is hovering over, remove any previous + // hover state, and set any new state. + fn set_hover_state( + &mut self, + state: &mut State, + x: u16, + y: u16, + ) -> Vec { + let current_id = self.find_intersection(state, x, y); + if current_id == self.hovered + && self.hovered != Some(state.rack_state.id()) + { + // No change + vec![] + } else { + self.hovered = current_id; + if self.hovered == Some(state.rack_state.id()) { + // Update the specific component being hovered over + if !state.rack_state.set_hover_state(x, y) { + // No need to redraw, as the component is the same as before + vec![] + } else { + vec![Action::Redraw] + } + } else { + state.rack_state.hovered = None; + vec![Action::Redraw] + } + } + } + + // Return if the coordinates interesct a given control. + // This assumes disjoint control rectangles. + fn find_intersection( + &self, + state: &State, + x: u16, + y: u16, + ) -> Option { + if self.help_button_state.intersects_point(x, y) { + Some(self.help_button_state.id()) + } else if state.rack_state.intersects_point(x, y) { + Some(state.rack_state.id()) + } else { + None + } + } +} + +impl Screen for RackScreen { + fn draw( + &mut self, + state: &State, + terminal: &mut crate::Term, + ) -> anyhow::Result<()> { + terminal.draw(|f| { + self.draw_background(f); + self.draw_rack(state, f); + self.draw_watermark(state, f); + self.draw_menubar(f); + })?; + Ok(()) + } + + fn on(&mut self, state: &mut State, event: ScreenEvent) -> Vec { + match event { + ScreenEvent::Term(TermEvent::Key(key_event)) => { + self.handle_key_event(state, key_event) + } + ScreenEvent::Term(TermEvent::Mouse(mouse_event)) => { + self.handle_mouse_event(state, mouse_event) + } + ScreenEvent::Tick => { + if let Some(k) = state.rack_state.knight_rider_mode.as_mut() { + k.step(); + } + + if !self.help_menu_state.is_closed() { + self.help_menu_state.step(); + vec![Action::Redraw] + } else if state.rack_state.knight_rider_mode.is_some() { + vec![Action::Redraw] + } else { + vec![] + } + } + _ => vec![], + } + } +} diff --git a/wicket/src/screens/splash.rs b/wicket/src/screens/splash.rs new file mode 100644 index 00000000000..fe2817fbf63 --- /dev/null +++ b/wicket/src/screens/splash.rs @@ -0,0 +1,97 @@ +// 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/. + +//! The splash [`Screen'] +//! +//! This is the first screen the user sees + +use super::colors::*; +use super::{Screen, ScreenId}; +use crate::widgets::{Logo, LogoState, LOGO_HEIGHT, LOGO_WIDTH}; +use crate::Action; +use crate::Frame; +use crate::ScreenEvent; +use crate::TermEvent; +use tui::style::{Color, Style}; +use tui::widgets::Block; + +const TOTAL_FRAMES: usize = 100; + +pub struct SplashScreen { + state: LogoState, +} + +impl SplashScreen { + pub fn new() -> SplashScreen { + SplashScreen { + state: LogoState { + frame: 0, + text: include_str!("../../banners/oxide.txt"), + }, + } + } + + fn draw_background(&self, f: &mut Frame) { + let style = Style::default().bg(Color::Black); + let block = Block::default().style(style); + f.render_widget(block, f.size()); + } + + // Sweep left to right, painting the banner white, with + // the x painted green. + fn animate_logo(&mut self, f: &mut Frame) { + // Center the banner + let mut rect = f.size(); + rect.x = rect.width / 2 - LOGO_WIDTH / 2; + rect.y = rect.height / 2 - LOGO_HEIGHT / 2; + rect.height = LOGO_HEIGHT; + rect.width = LOGO_WIDTH; + + let stale_style = Style::default().fg(OX_GREEN_DARKEST); + let style = Style::default().fg(OX_OFF_WHITE); + let x_style = Style::default().fg(OX_GREEN_LIGHT); + let logo = Logo::new(&self.state) + .stale_style(stale_style) + .style(style) + .x_style(x_style); + + f.render_widget(logo, rect); + } +} + +impl Screen for SplashScreen { + fn draw( + &mut self, + _state: &crate::State, + terminal: &mut crate::Term, + ) -> anyhow::Result<()> { + terminal.draw(|f| { + self.draw_background(f); + self.animate_logo(f); + })?; + Ok(()) + } + + fn on( + &mut self, + _state: &mut crate::State, + event: ScreenEvent, + ) -> Vec { + match event { + ScreenEvent::Tick => { + self.state.frame += 1; + if self.state.frame < TOTAL_FRAMES { + vec![Action::Redraw] + } else { + vec![Action::SwitchScreen(ScreenId::Rack)] + } + } + ScreenEvent::Term(TermEvent::Key(_)) => { + // Allow the user to skip the splash screen with any key press + vec![Action::SwitchScreen(ScreenId::Rack)] + } + _ => vec![], + } + } +} diff --git a/wicket/src/widgets/animated_logo.rs b/wicket/src/widgets/animated_logo.rs new file mode 100644 index 00000000000..adcb5383224 --- /dev/null +++ b/wicket/src/widgets/animated_logo.rs @@ -0,0 +1,87 @@ +// 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/. + +//! Animated Oxide logo used for the splash screen + +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::Style; +use tui::widgets::Widget; + +pub const LOGO_HEIGHT: u16 = 7; +pub const LOGO_WIDTH: u16 = 46; + +pub struct LogoState { + // The current animation frame + pub frame: usize, + + // The text of the logo in "# banner" form + pub text: &'static str, +} + +// We don't need a `StatefulWidget`, since state is never updated during drawing. +// We just borrow the `LogoState` as part of `Logo`. +pub struct Logo<'a> { + state: &'a LogoState, + // The style of the not-yet-hightlighted letters + stale_style: Style, + // The style of the highlighted letters besides the `x`. + style: Style, + // The style of the highlighted `x` + x_style: Style, +} + +// Styling is mandatory! +impl<'a> Logo<'a> { + pub fn new(state: &'a LogoState) -> Logo { + Logo { + state, + stale_style: Style::default(), + style: Style::default(), + x_style: Style::default(), + } + } + + pub fn stale_style(mut self, style: Style) -> Logo<'a> { + self.stale_style = style; + self + } + pub fn style(mut self, style: Style) -> Logo<'a> { + self.style = style; + self + } + + pub fn x_style(mut self, style: Style) -> Logo<'a> { + self.x_style = style; + self + } +} + +impl<'a> Widget for Logo<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + // Delay painting for 8 frames + let paint_point = + if self.state.frame < 8 { 0 } else { self.state.frame - 8 }; + for (y, line) in self.state.text.lines().enumerate() { + for (x, c) in line.chars().enumerate() { + if c == '#' { + let cell = buf + .get_mut(x as u16 + area.left(), y as u16 + area.top()) + .set_symbol(" "); + if x < paint_point { + // The cell is highlighted + if !(11..=17).contains(&x) { + cell.set_bg(self.style.fg.unwrap()); + } else { + // We're painting the Oxide `x` + cell.set_bg(self.x_style.fg.unwrap()); + } + } else { + cell.set_bg(self.stale_style.fg.unwrap()); + } + } + } + } + } +} diff --git a/wicket/src/widgets/banner.rs b/wicket/src/widgets/banner.rs new file mode 100644 index 00000000000..afd0c2dafa9 --- /dev/null +++ b/wicket/src/widgets/banner.rs @@ -0,0 +1,83 @@ +// 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/. + +use std::str::Lines; +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::Style; +use tui::widgets::Widget; + +/// A banner draws the text output from `banner` as stored in string. +/// +/// This assumes the source string is ASCII and not Unicode. This should be +/// fine Since `banner` outputs `#` characters and whitespace only. +/// +/// If the Banner is smaller than the Rect being rendered to, it won't be +/// rendered at all. Should we provide an option to print a partial +/// banner instead of nothing in this case? +pub struct Banner<'a> { + lines: Lines<'a>, + style: Style, +} + +impl<'a> Banner<'a> { + pub fn new(text: &'a str) -> Banner<'a> { + Banner { lines: text.lines(), style: Style::default() } + } + + pub fn style(mut self, style: Style) -> Banner<'a> { + self.style = style; + self + } + + pub fn height(&self) -> u16 { + self.lines.clone().count().try_into().unwrap() + } + + pub fn width(&self) -> u16 { + self.lines + .clone() + .map(|l| l.len()) + .max() + .unwrap_or(0) + .try_into() + .unwrap() + } +} + +impl<'a> Widget for Banner<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.height < self.height() || area.width < self.width() { + // Don't draw anything if the banner doesn't fit in `area` + return; + } + + for (y, line) in self.lines.enumerate() { + let mut len: u16 = 0; + for (x, c) in line.chars().enumerate() { + let cell = buf + .get_mut(x as u16 + area.left(), y as u16 + area.top()) + .set_symbol(" "); + if c == '#' { + if let Some(fg) = self.style.fg { + cell.set_bg(fg); + } + } else if let Some(bg) = self.style.bg { + cell.set_bg(bg); + } + len = x as u16; + } + + // Fill out the rest of the line with the background color + let start = area.left() + len + 1; + for x in start..area.right() { + let cell = + buf.get_mut(x, y as u16 + area.top()).set_symbol(" "); + if let Some(bg) = self.style.bg { + cell.set_bg(bg); + } + } + } + } +} diff --git a/wicket/src/widgets/help_button.rs b/wicket/src/widgets/help_button.rs new file mode 100644 index 00000000000..f2d7084533c --- /dev/null +++ b/wicket/src/widgets/help_button.rs @@ -0,0 +1,73 @@ +// 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/. + +//! A help button that brings up a [`HelpMenu`] when selected + +use super::get_control_id; +use super::Control; +use super::ControlId; +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::Style; +use tui::text::Text; +use tui::widgets::Block; +use tui::widgets::BorderType; +use tui::widgets::Borders; +use tui::widgets::Paragraph; +use tui::widgets::Widget; + +#[derive(Debug)] +pub struct HelpButtonState { + control_id: ControlId, + pub rect: Rect, +} + +impl HelpButtonState { + pub fn new(x: u16, y: u16) -> HelpButtonState { + HelpButtonState { + control_id: get_control_id(), + rect: Rect { height: 5, width: 12, x, y }, + } + } +} + +impl Control for HelpButtonState { + fn id(&self) -> ControlId { + self.control_id + } + + fn rect(&self) -> Rect { + self.rect + } +} + +#[derive(Debug)] +pub struct HelpButton<'a> { + pub state: &'a HelpButtonState, + pub style: Style, + pub border_style: Style, +} + +impl<'a> HelpButton<'a> { + pub fn new( + state: &'a HelpButtonState, + style: Style, + border_style: Style, + ) -> HelpButton<'a> { + HelpButton { state, style, border_style } + } +} + +impl<'a> Widget for HelpButton<'a> { + fn render(self, _: Rect, buf: &mut Buffer) { + let text = Text::from(" Help \n ━━━━━━━\n ctrl-h "); + let button = Paragraph::new(text).style(self.style).block( + Block::default() + .style(self.border_style) + .borders(Borders::ALL) + .border_type(BorderType::Double), + ); + button.render(self.state.rect, buf); + } +} diff --git a/wicket/src/widgets/help_menu.rs b/wicket/src/widgets/help_menu.rs new file mode 100644 index 00000000000..fe72a6978bd --- /dev/null +++ b/wicket/src/widgets/help_menu.rs @@ -0,0 +1,121 @@ +// 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/. + +//! The help menu for the system + +use super::animate_clear_buf; +use super::AnimationState; +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::{Modifier, Style}; +use tui::text::Text; +use tui::widgets::Paragraph; +use tui::widgets::Widget; +use tui::widgets::{Block, Borders}; + +#[derive(Default)] +pub struct HelpMenuState(Option); + +impl HelpMenuState { + pub fn get_animation_state(&self) -> Option { + self.0 + } + + // Return true if there is no [`AnimationState`] + pub fn is_closed(&self) -> bool { + self.0.is_none() + } + + // Toggle the display of the help menu + pub fn toggle(&mut self) { + if self.is_closed() { + self.open() + } else { + self.close(); + } + } + + // Perform an animation step + pub fn step(&mut self) { + let state = self.0.as_mut().unwrap(); + let done = state.step(); + let is_closed = state.is_closing() && done; + if is_closed { + self.0 = None; + } + } + + pub fn open(&mut self) { + self.0 + .get_or_insert(AnimationState::Opening { frame: 0, frame_max: 8 }); + } + + pub fn close(&mut self) { + let state = self.0.take(); + match state { + None => (), // Already closed + Some(AnimationState::Opening { frame, frame_max }) => { + // Transition to closing at the same position in the animation + self.0 = Some(AnimationState::Closing { frame, frame_max }); + } + Some(s) => { + // Already closing. Maintain same state + self.0 = Some(s); + } + } + } +} + +#[derive(Debug)] +pub struct HelpMenu<'a> { + pub help: &'a [(&'a str, &'a str)], + pub style: Style, + pub command_style: Style, + pub state: AnimationState, +} + +impl<'a> Widget for HelpMenu<'a> { + fn render(self, mut rect: Rect, buf: &mut Buffer) { + let mut text = Text::styled( + "HELP\n\n", + self.style + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED), + ); + + for (cmd, desc) in self.help { + text.extend(Text::styled(*cmd, self.command_style)); + text.extend(Text::styled(format!(" {desc}"), self.style)); + text.extend(Text::styled("\n", self.style)); + } + + let text_width: u16 = text.width().try_into().unwrap(); + let text_height: u16 = text.height().try_into().unwrap(); + + let xshift = 5; + let yshift = 1; + + // Draw the background and some borders + let mut bg = rect; + bg.width = text_width + xshift * 2; + bg.height = text_height + yshift * 3 / 2 + 2; + let drawn_bg = animate_clear_buf(bg, buf, self.style, self.state); + let bg_block = + Block::default().border_style(self.style).borders(Borders::ALL); + bg_block.render(drawn_bg, buf); + + // Draw the menu text if bg animation is done opening + // + // Note that render may be called during closing also, and so this will + // not get called in that case + if self.state.is_opening() && self.state.is_done() { + let menu = Paragraph::new(text); + rect.x = xshift; + rect.y = yshift; + rect.width = text_width; + rect.height = text_height; + menu.render(rect, buf); + } + } +} diff --git a/wicket/src/widgets/mod.rs b/wicket/src/widgets/mod.rs new file mode 100644 index 00000000000..5215d15ab53 --- /dev/null +++ b/wicket/src/widgets/mod.rs @@ -0,0 +1,145 @@ +// 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/. + +//! Custom tui widgets + +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::Style; + +mod animated_logo; +mod banner; +mod help_button; +mod help_menu; +mod rack; +mod screen_button; + +pub use animated_logo::{Logo, LogoState, LOGO_HEIGHT, LOGO_WIDTH}; +pub use banner::Banner; +pub use help_button::{HelpButton, HelpButtonState}; +pub use help_menu::HelpMenu; +pub use help_menu::HelpMenuState; +pub use rack::{KnightRiderMode, Rack, RackState}; +pub use screen_button::{ScreenButton, ScreenButtonState}; + +/// A unique id for a [`Control`] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ControlId(pub usize); + +/// Return a unique id for a [`Control`] +pub fn get_control_id() -> ControlId { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + ControlId(COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +/// A control is an interactive object on a [`Screen`]. +/// +/// Control's are often the internal state of [`tui::Widget`]s and are used to +/// manage how the Widgets are drawn. +pub trait Control { + fn id(&self) -> ControlId; + + /// Return the rectangle of the control to be intersected. + fn rect(&self) -> Rect; + + /// Return true if the rect of the control intersects the rect passed in. + fn intersects(&self, rect: Rect) -> bool { + self.rect().intersects(rect) + } + + /// Return true if the control intersects with the given point + fn intersects_point(&self, x: u16, y: u16) -> bool { + self.rect().intersects(Rect { x, y, width: 1, height: 1 }) + } +} + +// Set the buf area to the bg color +pub fn clear_buf(area: Rect, buf: &mut Buffer, style: Style) { + for x in area.left()..area.right() { + for y in area.top()..area.bottom() { + buf.get_mut(x, y).set_style(style).set_symbol(" "); + } + } +} + +/// Animate expansion of a rec diagonally from top-left to bottom-right and +/// drawing the bg color. +/// +/// Return the Rect that was drawn +pub fn animate_clear_buf( + mut rect: Rect, + buf: &mut Buffer, + style: Style, + state: AnimationState, +) -> Rect { + rect.width = rect.width * state.frame() / state.frame_max(); + rect.height = rect.height * state.frame() / state.frame_max(); + clear_buf(rect, buf, style); + rect +} + +#[derive(Debug, Clone, Copy)] +pub enum AnimationState { + // Count up from frame = 0 until frame = frame_max + Opening { frame: u16, frame_max: u16 }, + // Count down from frame = frame_max until frame = 0 + Closing { frame: u16, frame_max: u16 }, +} + +impl AnimationState { + pub fn is_done(&self) -> bool { + match self { + AnimationState::Opening { frame, frame_max } => frame == frame_max, + AnimationState::Closing { frame, .. } => *frame == 0, + } + } + + // Animate one frame + // + /// Return true if animation is complete + pub fn step(&mut self) -> bool { + match self { + AnimationState::Opening { frame, frame_max } => { + if frame != frame_max { + *frame += 1; + false + } else { + true + } + } + AnimationState::Closing { frame, .. } => { + if *frame != 0 { + *frame -= 1; + false + } else { + true + } + } + } + } + + pub fn is_opening(&self) -> bool { + matches!(self, AnimationState::Opening { .. }) + } + + pub fn is_closing(&self) -> bool { + !self.is_opening() + } + + pub fn frame(&self) -> u16 { + match self { + AnimationState::Closing { frame, .. } => *frame, + AnimationState::Opening { frame, .. } => *frame, + } + } + + pub fn frame_max(&self) -> u16 { + match self { + AnimationState::Closing { frame_max, .. } => *frame_max, + AnimationState::Opening { frame_max, .. } => *frame_max, + } + } +} diff --git a/wicket/src/widgets/rack.rs b/wicket/src/widgets/rack.rs new file mode 100644 index 00000000000..20ce95c193b --- /dev/null +++ b/wicket/src/widgets/rack.rs @@ -0,0 +1,528 @@ +// 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/. + +//! A rendering of the Oxide rack + +use super::get_control_id; +use super::Control; +use super::ControlId; +use crate::inventory::ComponentId; +use crate::screens::make_even; +use crate::screens::Height; +use crate::screens::TabIndex; +use slog::Logger; +use std::collections::BTreeMap; +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::Color; +use tui::style::Style; +use tui::widgets::Block; +use tui::widgets::Borders; +use tui::widgets::Widget; + +#[derive(Debug, Clone)] +pub struct Rack<'a> { + pub state: &'a RackState, + pub sled_style: Style, + pub sled_selected_style: Style, + pub switch_style: Style, + pub switch_selected_style: Style, + pub power_shelf_style: Style, + pub power_shelf_selected_style: Style, + pub border_style: Style, + pub border_selected_style: Style, + pub border_hover_style: Style, +} + +impl<'a> Rack<'a> { + fn draw_sled(&self, buf: &mut Buffer, sled: &Rect, i: u8) { + let component_id = Some(ComponentId::Sled(i)); + let mut block = Block::default() + .title(format!("sled {}", i)) + .borders(borders(sled.height)); + if self.state.tabbed == component_id { + block = block + .style(self.sled_selected_style) + .border_style(self.border_selected_style); + } else { + block = + block.style(self.sled_style).border_style(self.border_style); + + if self.state.hovered == component_id { + block = block.border_style(self.border_hover_style) + } + } + + let inner = block.inner(*sled); + block.render(*sled, buf); + + // Draw some U.2 bays + // TODO: Draw 10 only? - That may not scale down as well + for x in inner.left()..inner.right() { + for y in inner.top()..inner.bottom() { + let cell = buf.get_mut(x, y).set_symbol("▕"); + if self.state.tabbed == component_id { + if let Some(KnightRiderMode { pos, .. }) = + self.state.knight_rider_mode + { + if x == (inner.left() + pos) { + cell.set_bg(Color::Red); + } + } + if let Some(color) = self.sled_selected_style.fg { + cell.set_fg(color); + } + } else if let Some(color) = self.sled_style.fg { + cell.set_fg(color); + } + } + } + } + + fn draw_switch(&self, buf: &mut Buffer, switch: &Rect, i: u8) { + let component_id = Some(ComponentId::Switch(i)); + let mut block = Block::default() + .title(format!("switch {}", i)) + .borders(borders(switch.height)); + if self.state.tabbed == component_id { + block = block + .style(self.switch_selected_style) + .border_style(self.border_selected_style); + } else { + block = + block.style(self.switch_style).border_style(self.border_style); + if self.state.hovered == component_id { + block = block.border_style(self.border_hover_style) + } + } + + let inner = block.inner(*switch); + block.render(*switch, buf); + + for x in inner.left()..inner.right() { + for y in inner.top()..inner.bottom() { + buf.get_mut(x, y).set_symbol("❒"); + } + } + } + + fn draw_power_shelf(&self, buf: &mut Buffer, power_shelf: &Rect, i: u8) { + let component_id = Some(ComponentId::Psc(i)); + let mut block = Block::default() + .title(format!("power {}", i)) + .borders(borders(power_shelf.height)); + if self.state.tabbed == component_id { + block = block + .style(self.power_shelf_selected_style) + .border_style(self.border_selected_style); + } else { + block = block + .style(self.power_shelf_style) + .border_style(self.border_style); + if self.state.hovered == component_id { + block = block.border_style(self.border_hover_style) + } + } + + let inner = block.inner(*power_shelf); + block.render(*power_shelf, buf); + + let width = inner.right() - inner.left(); + let step = width / 6; + let border = (width - step * 6) / 2; + + for x in inner.left() + border..inner.right() - border { + for y in inner.top()..inner.bottom() { + if x % step != 0 { + buf.get_mut(x, y).set_symbol("█"); + } + } + } + } +} + +// Each of the top and bottom borders take one line. The rendering looks +// better with all borders, but to save space, we don't draw the bottom +// border if we don't have 3 lines available. +fn borders(height: u16) -> Borders { + if height < 3 { + Borders::TOP | Borders::LEFT | Borders::RIGHT + } else { + Borders::ALL + } +} + +impl<'a> Widget for Rack<'a> { + fn render(self, _area: Rect, buf: &mut Buffer) { + for (id, rect) in self.state.component_rects.iter() { + match id { + ComponentId::Sled(i) => self.draw_sled(buf, rect, *i), + ComponentId::Switch(i) => self.draw_switch(buf, rect, *i), + ComponentId::Psc(i) => self.draw_power_shelf(buf, rect, *i), + } + } + } +} + +// Currently we only allow tabbing through the rack +// There are 36 entries (0 - 35) +const MAX_TAB_INDEX: u16 = 35; + +// Easter egg alert: Support for Knight Rider mode +#[derive(Debug, Default)] +pub struct KnightRiderMode { + width: u16, + pos: u16, + move_left: bool, +} + +impl KnightRiderMode { + pub fn step(&mut self) { + if self.pos == 0 && self.move_left { + self.pos = 1; + self.move_left = false; + } else if (self.pos == self.width) && !self.move_left { + self.move_left = true; + self.pos = self.width - 1; + } else if self.move_left { + self.pos -= 1; + } else { + self.pos += 1; + } + } +} + +// The visual state of the rack +#[derive(Debug)] +pub struct RackState { + control_id: ControlId, + pub log: Option, + pub rect: Rect, + pub hovered: Option, + pub tabbed: Option, + pub component_rects: BTreeMap, + pub tab_index: TabIndex, + pub tab_index_by_component_id: BTreeMap, + pub component_id_by_tab_index: BTreeMap, + pub knight_rider_mode: Option, +} + +#[allow(clippy::new_without_default)] +impl RackState { + pub fn new() -> RackState { + let mut state = RackState { + control_id: get_control_id(), + log: None, + rect: Rect::default(), + hovered: None, + tabbed: None, + component_rects: BTreeMap::new(), + tab_index: TabIndex::new_unset(MAX_TAB_INDEX), + tab_index_by_component_id: BTreeMap::new(), + component_id_by_tab_index: BTreeMap::new(), + knight_rider_mode: None, + }; + + for i in 0..32 { + state.component_rects.insert(ComponentId::Sled(i), Rect::default()); + } + + for i in 0..2 { + state + .component_rects + .insert(ComponentId::Switch(i), Rect::default()); + state.component_rects.insert(ComponentId::Psc(i), Rect::default()); + } + + state.init_tab_index(); + state + } + + pub fn toggle_knight_rider_mode(&mut self) { + if self.knight_rider_mode.is_none() { + let mut kr = KnightRiderMode::default(); + kr.width = self.rect.width / 2 - 2; + self.knight_rider_mode = Some(kr); + } else { + self.knight_rider_mode = None; + } + } + + /// We call this when the mouse cursor intersects the rack. This figures out which + /// component intersects and sets `self.hovered`. + /// + /// Return true if the component being hovered over changed, false otherwise. This + /// allows us to limit the number of re-draws necessary. + pub fn set_hover_state(&mut self, x: u16, y: u16) -> bool { + // Find the interesecting component. + // I'm sure there's a faster way to do this with percentages, etc.., + // but this works for now. + for (id, rect) in self.component_rects.iter() { + let mouse_pointer = Rect { x, y, width: 1, height: 1 }; + if rect.intersects(mouse_pointer) { + if self.hovered == Some(*id) { + // No chnage + return false; + } else { + self.hovered = Some(*id); + return true; + } + } + } + if self.hovered == None { + // No change + false + } else { + self.hovered = None; + true + } + } + + pub fn inc_tab_index(&mut self) { + self.tab_index.inc(); + let id = self.get_current_component_id(); + self.tabbed = Some(id); + } + + pub fn dec_tab_index(&mut self) { + self.tab_index.dec(); + let id = self.get_current_component_id(); + self.tabbed = Some(id); + } + + pub fn clear_tab_index(&mut self) { + self.tab_index.clear(); + self.tabbed = None; + } + + pub fn set_tab_from_hovered(&mut self) { + self.tabbed = self.hovered; + if let Some(id) = self.tabbed { + self.set_tab(id); + } + } + + pub fn set_tab(&mut self, id: ComponentId) { + self.tabbed = Some(id); + if let Some(tab_index) = self.tab_index_by_component_id.get(&id) { + self.tab_index = *tab_index; + } + } + + pub fn set_logger(&mut self, log: Logger) { + self.log = Some(log); + } + + pub fn get_current_component_id(&self) -> ComponentId { + *self.component_id_by_tab_index.get(&self.tab_index).unwrap() + } + + pub fn get_next_component_id(&self) -> ComponentId { + let next = self.tab_index.next(); + *self.component_id_by_tab_index.get(&next).unwrap() + } + + pub fn get_prev_component_id(&self) -> ComponentId { + let prev = self.tab_index.prev(); + *self.component_id_by_tab_index.get(&prev).unwrap() + } + + // We need to size the rack for large and small terminals + // Width is not the issue, but height is. + // We have the following constraints: + // + // * Sleds, switches, and power shelves must be at least 2 lines high + // so they have a top border with label and black margin and one line for + // content drawing. + // * The top borders give a bottom border automatically to each component + // above them, and the top of the rack. However we also need a bottom + // border, so that's one more line. + // + // Therefore the minimum height of the rack is 2*16 + 2*2 + 2*2 + 1 = 41 lines. + // + // With a 5 line margin at the top, the minimum size of the terminal is 46 + // lines. + // + // If the terminal is smaller than 46 lines, the artistic rendering will + // get progressively worse. XXX: We may want to bail on this instead. + // + // As much as possible we would like to also have a bottom margin, but we + // don't sweat it. + // + // The calculations below follow from this logic + pub fn resize(&mut self, rect: &Rect, margin: &Height) { + // Give ourself room for a top margin + let max_height = rect.height - margin.0; + + // Let's size our components + let (rack_height, sled_height, other_height): (u16, u16, u16) = + if max_height < 20 { + panic!( + "Terminal size must be at least {} lines long", + 20 + margin.0 + ); + } else if max_height < 37 { + (21, 1, 1) + } else if max_height < 41 { + (37, 2, 1) + } else if max_height < 56 { + (41, 2, 2) + } else if max_height < 60 { + (56, 3, 2) + } else if max_height < 80 { + // 80 = 4*16 (sled bays) + 4*2 (power shelves) + 4*2 (switches) + (60, 3, 3) + } else { + // Just divide evenly by 20 (16 sleds + 2 power shelves + 2 switches) + let component_height = max_height / 20; + (component_height * 20, component_height, component_height) + }; + + let mut rect = *rect; + rect.height = rack_height; + + // Center the rack vertically as much as possible + let extra_margin = (max_height - rack_height) / 2; + rect.y = margin.0 + extra_margin; + + // Scale proportionally and center the rack horizontally + let width = rect.width; + rect.width = make_even(rack_height * 2 / 3); + rect.x = width / 2 - rect.width / 2; + + let sled_width = rect.width / 2; + + // Save our rack rect for later + self.rect = rect; + + // Is KnightRiderModeEnabled. Need to reset the width. + if let Some(kr) = &mut self.knight_rider_mode { + kr.pos = 0; + kr.move_left = false; + kr.width = rect.width / 2 - 2; + } + + // Top Sleds + for i in 0..16u8 { + self.size_sled(i, &rect, sled_height, sled_width, other_height); + } + + // Top Switch + let switch = + self.component_rects.get_mut(&ComponentId::Switch(1)).unwrap(); + switch.y = rect.y + sled_height * 8; + switch.x = rect.x; + switch.height = other_height; + switch.width = sled_width * 2; + + // Power Shelves + for i in [17, 18] { + let shelf = self + .component_rects + .get_mut(&ComponentId::Psc(18 - i)) + .unwrap(); + shelf.y = rect.y + sled_height * 8 + other_height * (i as u16 - 16); + shelf.x = rect.x; + shelf.height = other_height; + shelf.width = sled_width * 2; + } + + // Bottom Switch + let switch = + self.component_rects.get_mut(&ComponentId::Switch(0)).unwrap(); + switch.y = rect.y + sled_height * 8 + 3 * other_height; + switch.x = rect.x; + switch.height = other_height; + switch.width = sled_width * 2; + + // Bottom Sleds + // We treat each non-sled as 2 sleds for layout purposes + for i in 24..40 { + self.size_sled(i, &rect, sled_height, sled_width, other_height); + } + } + + // Sleds are numbered bottom-to-top and left-to-rigth + // See https://rfd.shared.oxide.computer/rfd/0200#_number_ordering + fn size_sled( + &mut self, + i: u8, + rack: &Rect, + sled_height: u16, + sled_width: u16, + other_height: u16, + ) { + // The power shelves and switches are in between in the layout + let index = if i < 16 { i } else { i - 8 }; + let mut sled = 31 - index; + // Swap left and right + if sled & 1 == 1 { + sled -= 1; + } else { + sled += 1; + } + let sled = + self.component_rects.get_mut(&ComponentId::Sled(sled)).unwrap(); + + if index < 16 { + sled.y = rack.y + sled_height * (index as u16 / 2); + } else { + sled.y = + rack.y + sled_height * (index as u16 / 2) + other_height * 4; + } + + if index % 2 == 0 { + // left sled + sled.x = rack.x + } else { + // right sled + sled.x = rack.x + sled_width; + } + if (index == 30 || index == 31) && sled_height == 2 { + // We saved space for a bottom border + sled.height = 3; + } else { + sled.height = sled_height; + } + sled.width = sled_width; + } + + // We tab bottom-to-top, left-to-right, same as component numbering + fn init_tab_index(&mut self) { + for i in 0..=MAX_TAB_INDEX as u8 { + let tab_index = TabIndex::new(MAX_TAB_INDEX, i as u16); + let component_id = if i < 16 { + ComponentId::Sled(i) + } else if i > 19 { + ComponentId::Sled(i - 4) + } else if i == 16 { + // Switches + ComponentId::Switch(0) + } else if i == 19 { + ComponentId::Switch(1) + } else if i == 17 || i == 18 { + // Power Shelves + // We actually want to return the active component here, so + // we name it "psc X" + ComponentId::Psc(i - 17) + } else { + // If we add more items to tab through this will change + unreachable!(); + }; + + self.component_id_by_tab_index.insert(tab_index, component_id); + self.tab_index_by_component_id.insert(component_id, tab_index); + } + } +} + +impl Control for RackState { + fn id(&self) -> ControlId { + self.control_id + } + + fn rect(&self) -> Rect { + self.rect + } +} diff --git a/wicket/src/widgets/screen_button.rs b/wicket/src/widgets/screen_button.rs new file mode 100644 index 00000000000..128a9342d28 --- /dev/null +++ b/wicket/src/widgets/screen_button.rs @@ -0,0 +1,90 @@ +// 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/. + +//! A help button that brings up a help menu when selected + +use super::get_control_id; +use super::Control; +use super::ControlId; +use crate::ScreenId; +use tui::buffer::Buffer; +use tui::layout::Rect; +use tui::style::Style; +use tui::text::Text; +use tui::widgets::Block; +use tui::widgets::BorderType; +use tui::widgets::Borders; +use tui::widgets::Paragraph; +use tui::widgets::Widget; + +#[derive(Debug)] +pub struct ScreenButtonState { + control_id: ControlId, + pub rect: Rect, + pub hovered: bool, + pub screen_id: ScreenId, +} + +impl ScreenButtonState { + pub fn new(screen_id: ScreenId, x: u16, y: u16) -> ScreenButtonState { + ScreenButtonState { + control_id: get_control_id(), + rect: Rect { height: 5, width: Self::width(), x, y }, + hovered: false, + screen_id, + } + } + + pub fn width() -> u16 { + ScreenId::width() + 5 + } +} + +impl Control for ScreenButtonState { + fn id(&self) -> ControlId { + self.control_id + } + + fn rect(&self) -> Rect { + self.rect + } +} + +#[derive(Debug)] +pub struct ScreenButton<'a> { + pub state: &'a ScreenButtonState, + pub style: Style, + pub border_style: Style, +} + +impl<'a> ScreenButton<'a> { + pub fn new( + state: &'a ScreenButtonState, + style: Style, + border_style: Style, + ) -> ScreenButton<'a> { + ScreenButton { state, style, border_style } + } +} + +impl<'a> Widget for ScreenButton<'a> { + fn render(self, _: Rect, buf: &mut Buffer) { + let name = self.state.screen_id.name(); + // Subtract borders + let width = ScreenButtonState::width() as usize - 2; + let text = Text::from(format!( + "{:^width$}\n ━━━━━━━━━━\n{:^width$}", + name, + "ctrl-r", + width = width, + )); + let button = Paragraph::new(text).style(self.style).block( + Block::default() + .style(self.border_style) + .borders(Borders::ALL) + .border_type(BorderType::Double), + ); + button.render(self.state.rect, buf); + } +}