diff --git a/citadel-realms/Cargo.toml b/citadel-realms/Cargo.toml index b07bc91..d4e3ef9 100644 --- a/citadel-realms/Cargo.toml +++ b/citadel-realms/Cargo.toml @@ -5,14 +5,18 @@ authors = ["Bruce Leidl "] homepage = "http://github.com/subgraph/citadel" edition = "2018" + [dependencies] libcitadel = { path = "../libcitadel" } libc = "0.2" -clap = "2.30.0" failure = "0.1.1" -toml = "0.4.5" -serde_derive = "1.0.27" -serde = "1.0.27" -termcolor = "0.3" -walkdir = "2" -lazy_static = "1.2.0" +termion = "1.5.1" +signal-hook = "0.1.7" + +[dependencies.cursive] +version = "=0.11.0" +default-features = false +features = [ "termion-backend" ] + +[dependencies.crossbeam-channel] +version = "0.3" diff --git a/citadel-realms/src/backend.rs b/citadel-realms/src/backend.rs new file mode 100644 index 0000000..f9ce06c --- /dev/null +++ b/citadel-realms/src/backend.rs @@ -0,0 +1,385 @@ +//! Backend using the pure-rust termion library. +//! +use termion; + +use self::termion::color as tcolor; +use self::termion::event::Event as TEvent; +use self::termion::event::Key as TKey; +use self::termion::event::MouseButton as TMouseButton; +use self::termion::event::MouseEvent as TMouseEvent; +use self::termion::input::{MouseTerminal, TermRead}; +use self::termion::raw::{IntoRawMode, RawTerminal}; +use self::termion::screen::AlternateScreen; +use self::termion::style as tstyle; +use crossbeam_channel::{self, select, Receiver, Sender}; + +use cursive::backend; +use cursive::event::{Event, Key, MouseButton, MouseEvent}; +use cursive::theme; +use cursive::vec::Vec2; + +use std::cell::{Cell, RefCell}; +use std::fs::File; +use std::io::{self,BufWriter,Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::os::unix::io::{AsRawFd,RawFd}; +use std::os::unix::thread::JoinHandleExt; + +use libcitadel::terminal::AnsiControl; +use signal_hook::iterator::Signals; +use libc; +use std::thread::JoinHandle; + +/// Copy of terminal backend with following fixes: +/// +/// (1) Termion sends wrong control sequence to end bold text attribute +/// (2) Resize thread will panic on resize after finish() +/// (3) Input thread blocks in read from terminal after finish() +/// +pub struct Backend { + terminal: + RefCell>>>>, + current_style: Cell, + + // Inner state required to parse input + last_button: Option, + + input_receiver: Receiver, + resize_receiver: Receiver<()>, + + tty_fd: RawFd, + input_thread: JoinHandle<()>, +} + +fn close_fd(fd: RawFd) -> io::Result<()> { + unsafe { + if libc::close(fd) != 0 { + return Err(io::Error::last_os_error()); + } + }; + Ok(()) +} + +fn pthread_kill(tid: libc::pthread_t, sig: libc::c_int) -> io::Result<()> { + unsafe { + if libc::pthread_kill(tid, sig) != 0 { + return Err(io::Error::last_os_error()); + } + }; + Ok(()) +} + +impl Backend { + /// Creates a new termion-based backend. + pub fn init() -> std::io::Result> { + // Use a ~8MB buffer + // Should be enough for a single screen most of the time. + let terminal = + RefCell::new(AlternateScreen::from(MouseTerminal::from( + BufWriter::with_capacity(8_000_000, File::create("/dev/tty")?) + .into_raw_mode()?, + ))); + + write!(terminal.borrow_mut(), "{}", termion::cursor::Hide)?; + + let (input_sender, input_receiver) = crossbeam_channel::unbounded(); + let (resize_sender, resize_receiver) = crossbeam_channel::bounded(0); + + let running = Arc::new(AtomicBool::new(true)); + + start_resize_thread( + resize_sender, + Arc::clone(&running), + ); + + // We want nonblocking input, but termion is blocking by default + // Read input from a separate thread + + let input = std::fs::File::open("/dev/tty").unwrap(); + let tty_fd = input.as_raw_fd(); + let input_thread = thread::spawn(move || { + let mut events = input.events(); + + // Take all the events we can + while let Some(Ok(event)) = events.next() { + // If we can't send, it means the receiving side closed, + // so just stop. + if input_sender.send(event).is_err() { + break; + } + } + running.store(false, Ordering::Relaxed); + }); + + let c = Backend { + terminal, + current_style: Cell::new(theme::ColorPair::from_256colors(0, 0)), + + last_button: None, + input_receiver, + resize_receiver, + tty_fd, + input_thread, + }; + + Ok(Box::new(c)) + } + + fn close_tty(&self) { + if let Err(e) = close_fd(self.tty_fd) { + warn!("error closing tty fd: {}", e); + } + } + + fn kill_thread(&self) { + if let Err(e) = pthread_kill(self.input_thread.as_pthread_t(), libc::SIGWINCH) { + warn!("error sending signal to input thread: {}", e); + } + } + + fn apply_colors(&self, colors: theme::ColorPair) { + with_color(&colors.front, |c| self.write(tcolor::Fg(c))); + with_color(&colors.back, |c| self.write(tcolor::Bg(c))); + } + + fn map_key(&mut self, event: TEvent) -> Event { + match event { + TEvent::Unsupported(bytes) => Event::Unknown(bytes), + TEvent::Key(TKey::Esc) => Event::Key(Key::Esc), + TEvent::Key(TKey::Backspace) => Event::Key(Key::Backspace), + TEvent::Key(TKey::Left) => Event::Key(Key::Left), + TEvent::Key(TKey::Right) => Event::Key(Key::Right), + TEvent::Key(TKey::Up) => Event::Key(Key::Up), + TEvent::Key(TKey::Down) => Event::Key(Key::Down), + TEvent::Key(TKey::Home) => Event::Key(Key::Home), + TEvent::Key(TKey::End) => Event::Key(Key::End), + TEvent::Key(TKey::PageUp) => Event::Key(Key::PageUp), + TEvent::Key(TKey::PageDown) => Event::Key(Key::PageDown), + TEvent::Key(TKey::Delete) => Event::Key(Key::Del), + TEvent::Key(TKey::Insert) => Event::Key(Key::Ins), + TEvent::Key(TKey::F(i)) if i < 12 => Event::Key(Key::from_f(i)), + TEvent::Key(TKey::F(j)) => Event::Unknown(vec![j]), + TEvent::Key(TKey::Char('\n')) => Event::Key(Key::Enter), + TEvent::Key(TKey::Char('\t')) => Event::Key(Key::Tab), + TEvent::Key(TKey::Char(c)) => Event::Char(c), + TEvent::Key(TKey::Ctrl('c')) => Event::Exit, + TEvent::Key(TKey::Ctrl(c)) => Event::CtrlChar(c), + TEvent::Key(TKey::Alt(c)) => Event::AltChar(c), + TEvent::Mouse(TMouseEvent::Press(btn, x, y)) => { + let position = (x - 1, y - 1).into(); + + let event = match btn { + TMouseButton::Left => MouseEvent::Press(MouseButton::Left), + TMouseButton::Middle => { + MouseEvent::Press(MouseButton::Middle) + } + TMouseButton::Right => { + MouseEvent::Press(MouseButton::Right) + } + TMouseButton::WheelUp => MouseEvent::WheelUp, + TMouseButton::WheelDown => MouseEvent::WheelDown, + }; + + if let MouseEvent::Press(btn) = event { + self.last_button = Some(btn); + } + + Event::Mouse { + event, + position, + offset: Vec2::zero(), + } + } + TEvent::Mouse(TMouseEvent::Release(x, y)) + if self.last_button.is_some() => + { + let event = MouseEvent::Release(self.last_button.unwrap()); + let position = (x - 1, y - 1).into(); + Event::Mouse { + event, + position, + offset: Vec2::zero(), + } + } + TEvent::Mouse(TMouseEvent::Hold(x, y)) + if self.last_button.is_some() => + { + let event = MouseEvent::Hold(self.last_button.unwrap()); + let position = (x - 1, y - 1).into(); + Event::Mouse { + event, + position, + offset: Vec2::zero(), + } + } + _ => Event::Unknown(vec![]), + } + } + + fn write(&self, content: T) + where + T: std::fmt::Display, + { + write!(self.terminal.borrow_mut(), "{}", content).unwrap(); + } +} + +impl backend::Backend for Backend { + fn finish(&mut self) { + write!( + self.terminal.get_mut(), + "{}{}", + termion::cursor::Show, + termion::cursor::Goto(1, 1) + ) + .unwrap(); + + write!( + self.terminal.get_mut(), + "{}[49m{}[39m{}", + 27 as char, + 27 as char, + termion::clear::All + ) + .unwrap(); + + self.close_tty(); + self.kill_thread(); + } + + fn set_color(&self, color: theme::ColorPair) -> theme::ColorPair { + let current_style = self.current_style.get(); + + if current_style != color { + self.apply_colors(color); + self.current_style.set(color); + } + + return current_style; + } + + fn set_effect(&self, effect: theme::Effect) { + match effect { + theme::Effect::Simple => (), + theme::Effect::Reverse => self.write(tstyle::Invert), + theme::Effect::Bold => self.write(tstyle::Bold), + theme::Effect::Italic => self.write(tstyle::Italic), + theme::Effect::Underline => self.write(tstyle::Underline), + } + } + + fn unset_effect(&self, effect: theme::Effect) { + match effect { + theme::Effect::Simple => (), + theme::Effect::Reverse => self.write(tstyle::NoInvert), + // XXX Fixed broken tstyle::NoBold + theme::Effect::Bold => self.write(AnsiControl::unbold().as_str()), + theme::Effect::Italic => self.write(tstyle::NoItalic), + theme::Effect::Underline => self.write(tstyle::NoUnderline), + } + } + + fn has_colors(&self) -> bool { + // TODO: color support detection? + true + } + + fn screen_size(&self) -> Vec2 { + // TODO: termion::terminal_size currently requires stdout. + // When available, we should try to use /dev/tty instead. + let (x, y) = termion::terminal_size().unwrap_or((1, 1)); + (x, y).into() + } + + fn clear(&self, color: theme::Color) { + self.apply_colors(theme::ColorPair { + front: color, + back: color, + }); + + self.write(termion::clear::All); + } + + fn refresh(&mut self) { + self.terminal.get_mut().flush().unwrap(); + } + + fn print_at(&self, pos: Vec2, text: &str) { + write!( + self.terminal.borrow_mut(), + "{}{}", + termion::cursor::Goto(1 + pos.x as u16, 1 + pos.y as u16), + text + ) + .unwrap(); + } + + fn poll_event(&mut self) -> Option { + let event = select! { + recv(self.input_receiver) -> event => event.ok(), + recv(self.resize_receiver) -> _ => return Some(Event::WindowResize), + default => return None, + }; + event.map(|event| self.map_key(event)) + } +} + +fn with_color(clr: &theme::Color, f: F) -> R +where + F: FnOnce(&dyn tcolor::Color) -> R, +{ + match *clr { + theme::Color::TerminalDefault => f(&tcolor::Reset), + theme::Color::Dark(theme::BaseColor::Black) => f(&tcolor::Black), + theme::Color::Dark(theme::BaseColor::Red) => f(&tcolor::Red), + theme::Color::Dark(theme::BaseColor::Green) => f(&tcolor::Green), + theme::Color::Dark(theme::BaseColor::Yellow) => f(&tcolor::Yellow), + theme::Color::Dark(theme::BaseColor::Blue) => f(&tcolor::Blue), + theme::Color::Dark(theme::BaseColor::Magenta) => f(&tcolor::Magenta), + theme::Color::Dark(theme::BaseColor::Cyan) => f(&tcolor::Cyan), + theme::Color::Dark(theme::BaseColor::White) => f(&tcolor::White), + + theme::Color::Light(theme::BaseColor::Black) => f(&tcolor::LightBlack), + theme::Color::Light(theme::BaseColor::Red) => f(&tcolor::LightRed), + theme::Color::Light(theme::BaseColor::Green) => f(&tcolor::LightGreen), + theme::Color::Light(theme::BaseColor::Yellow) => { + f(&tcolor::LightYellow) + } + theme::Color::Light(theme::BaseColor::Blue) => f(&tcolor::LightBlue), + theme::Color::Light(theme::BaseColor::Magenta) => { + f(&tcolor::LightMagenta) + } + theme::Color::Light(theme::BaseColor::Cyan) => f(&tcolor::LightCyan), + theme::Color::Light(theme::BaseColor::White) => f(&tcolor::LightWhite), + + theme::Color::Rgb(r, g, b) => f(&tcolor::Rgb(r, g, b)), + theme::Color::RgbLowRes(r, g, b) => { + f(&tcolor::AnsiValue::rgb(r, g, b)) + } + } +} + + +/// This starts a new thread to listen for SIGWINCH signals +#[allow(unused)] +pub fn start_resize_thread( + resize_sender: Sender<()>, resize_running: Arc, +) { + let signals = Signals::new(&[libc::SIGWINCH]).unwrap(); + thread::spawn(move || { + // This thread will listen to SIGWINCH events and report them. + while resize_running.load(Ordering::Relaxed) { + // We know it will only contain SIGWINCH signals, so no need to check. + if signals.wait().count() > 0 { + // XXX fixed to avoid panic + if resize_running.load(Ordering::Relaxed) { + if let Err(_) = resize_sender.send(()) { + return; + } + } + } + } + }); +} diff --git a/citadel-realms/src/config.rs b/citadel-realms/src/config.rs deleted file mode 100644 index fa17d9e..0000000 --- a/citadel-realms/src/config.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::path::Path; -use std::fs; -use toml; - -lazy_static! { - pub static ref GLOBAL_CONFIG: RealmConfig = RealmConfig::load_global_config(); -} - -const DEFAULT_ZONE: &str = "clear"; -const DEFAULT_REALMFS: &str = "base"; - -#[derive (Deserialize,Clone)] -pub struct RealmConfig { - #[serde(rename="use-shared-dir")] - use_shared_dir: Option, - - #[serde(rename="use-ephemeral-home")] - use_ephemeral_home: Option, - - #[serde(rename="use-sound")] - use_sound: Option, - - #[serde(rename="use-x11")] - use_x11: Option, - - #[serde(rename="use-wayland")] - use_wayland: Option, - - #[serde(rename="use-kvm")] - use_kvm: Option, - - #[serde(rename="use-gpu")] - use_gpu: Option, - - #[serde(rename="use-network")] - use_network: Option, - - #[serde(rename="network-zone")] - network_zone: Option, - - realmfs: Option, - - #[serde(rename="realmfs-write")] - realmfs_write: Option, - - #[serde(skip)] - parent: Option>, - -} - -impl RealmConfig { - - pub fn load_or_default>(path: P) -> RealmConfig { - match RealmConfig::load_config(path) { - Some(config) => config, - None => GLOBAL_CONFIG.clone() - } - } - - fn load_global_config() -> RealmConfig { - if let Some(mut global) = RealmConfig::load_config("/storage/realms/config") { - global.parent = Some(Box::new(RealmConfig::default())); - return global; - } - RealmConfig::default() - } - - fn load_config>(path: P) -> Option { - if path.as_ref().exists() { - match fs::read_to_string(path.as_ref()) { - Ok(s) => return toml::from_str::(&s).ok(), - Err(e) => warn!("Error reading config file: {}", e), - } - } - None - } - - pub fn default() -> RealmConfig { - RealmConfig { - use_shared_dir: Some(true), - use_ephemeral_home: Some(false), - use_sound: Some(true), - use_x11: Some(true), - use_wayland: Some(true), - use_kvm: Some(false), - use_gpu: Some(false), - use_network: Some(true), - network_zone: Some(DEFAULT_ZONE.into()), - realmfs: Some(DEFAULT_REALMFS.into()), - realmfs_write: Some(false), - parent: None, - } - } - - pub fn kvm(&self) -> bool { - self.bool_value(|c| c.use_kvm) - } - - pub fn gpu(&self) -> bool { - self.bool_value(|c| c.use_gpu) - } - - pub fn shared_dir(&self) -> bool { - self.bool_value(|c| c.use_shared_dir) - } - - pub fn emphemeral_home(&self) -> bool { - self.bool_value(|c| c.use_ephemeral_home) - } - - pub fn sound(&self) -> bool { - self.bool_value(|c| c.use_sound) - } - - pub fn x11(&self) -> bool { - self.bool_value(|c| c.use_x11) - } - - pub fn wayland(&self) -> bool { - self.bool_value(|c| c.use_network) - } - - pub fn network(&self) -> bool { - self.bool_value(|c| c.use_network) - } - - pub fn network_zone(&self) -> &str { - self.str_value(|c| c.network_zone.as_ref()) - } - - pub fn realmfs(&self) -> &str { - self.str_value(|c| c.realmfs.as_ref()) - } - - pub fn realmfs_write(&self) -> bool { - self.bool_value(|c| c.realmfs_write) - } - - fn str_value(&self, get: F) -> &str - where F: Fn(&RealmConfig) -> Option<&String> - { - if let Some(ref val) = get(self) { - return val - } - if let Some(ref parent) = self.parent { - if let Some(val) = get(parent) { - return val; - } - } - "" - } - - fn bool_value(&self, get: F) -> bool - where F: Fn(&RealmConfig) -> Option - { - if let Some(val) = get(self) { - return val - } - - if let Some(ref parent) = self.parent { - return get(parent).unwrap_or(false); - } - false - } -} diff --git a/citadel-realms/src/dialogs.rs b/citadel-realms/src/dialogs.rs new file mode 100644 index 0000000..37af036 --- /dev/null +++ b/citadel-realms/src/dialogs.rs @@ -0,0 +1,477 @@ +use cursive::views::{Dialog, TextView, OnEventView, PaddedView, DialogFocus, EditView, ListView, LinearLayout, DummyView }; +use cursive::traits::{View, Finder,Boxable,Identifiable,Scrollable}; +use cursive::event::{EventResult, Event, EventTrigger}; +use cursive::event::Key; +use cursive::Cursive; +use std::rc::Rc; +use cursive::view::ViewWrapper; +use cursive::direction::Direction; +use cursive::theme::ColorStyle; + + +pub fn confirm_dialog(title: &str, message: &str, cb: F) -> impl View + where F: 'static + Fn(&mut Cursive) +{ + let content = PaddedView::new((2,2,2,1), TextView::new(message)); + let dialog = Dialog::around(content) + .title(title) + .button("Yes", move |s| { + s.pop_layer(); + (cb)(s); + }) + .dismiss_button("No"); + + OnEventView::new(dialog) + .on_event_inner('y', |d: &mut Dialog, _| { + Some(d.on_event(Event::Key(Key::Left))) + }) + .on_event_inner('n', move |d: &mut Dialog, _| { + Some(d.on_event(Event::Key(Key::Right))) + }) + // Eat these global events + .on_event_inner('?', |_,_| { + Some(EventResult::Consumed(None)) + }) + .on_event_inner('T', |_,_| { + Some(EventResult::Consumed(None)) + }) + +} + + +// Set focus on dialog button at index `idx` by injecting events +// into the Dialog view. +// +// If the dialog content is currently in focus send Tab +// character to move focus to first button. +// +// Then inject Right/Left key events as needed to select +// the correct index. +pub fn select_dialog_button_index(dialog: &mut Dialog, idx: usize) { + let mut current = match dialog.focus() { + DialogFocus::Content => { + dialog.on_event(Event::Key(Key::Tab)); + 0 + }, + DialogFocus::Button(n) => { + n + }, + }; + while current < idx { + dialog.on_event(Event::Key(Key::Right)); + current += 1; + } + while current > idx { + dialog.on_event(Event::Key(Key::Left)); + current -= 1; + } +} + +pub fn keyboard_navigation_adapter(dialog: Dialog, keys: &'static str) -> OnEventView { + // a trigger that matches any character in 'keys' + let trigger = EventTrigger::from_fn(move |ev| match ev { + Event::Char(c) => keys.contains(|ch: char| ch == *c), + _ => false, + }); + OnEventView::new(dialog) + + // The button navigation is a hack that depends on Dialog internal behavior + .on_event_inner(trigger, move |d: &mut Dialog,ev| { + if let Event::Char(c) = ev { + if let Some(idx) = keys.find(|ch: char| ch == *c) { + select_dialog_button_index(d, idx); + return Some(EventResult::Consumed(None)) + } + } + None + }) + + .on_event_inner(Key::Enter, |v,_| Some(v.on_event(Event::Key(Key::Down)))) + + // 'q' to close dialog, but first see if some component of the dialog + // (such as a text field) wants this event + .on_pre_event_inner('q', |v,e| { + let result = match v.on_event(e.clone()) { + EventResult::Consumed(cb) => EventResult::Consumed(cb), + EventResult::Ignored => EventResult::with_cb(|s| {s.pop_layer();}), + }; + Some(result) + }) + + // Eat these global events + .on_event_inner('?', |_,_| { + Some(EventResult::Consumed(None)) + }) + .on_event_inner('T', |_,_| { + Some(EventResult::Consumed(None)) + }) + + .on_event(Key::Esc, |s| { + s.pop_layer(); + }) +} + +pub struct FieldDialogBuilder { + layout: FieldLayout, + id: &'static str, + title: Option<&'static str>, + height: Option, +} + +#[allow(dead_code)] +impl FieldDialogBuilder { + + const DEFAULT_ID: &'static str = "field-dialog"; + + pub fn new(labels: &[&str], message: &str) -> Self { + FieldDialogBuilder { + layout: FieldLayout::new(labels, message), + id: Self::DEFAULT_ID, + title: None, + height: None, + } + } + + + pub fn add_edit_view(&mut self, id: &str, width: usize) { + self.layout.add_edit_view(id, width); + } + + pub fn edit_view(mut self, id: &str, width: usize) -> Self { + self.add_edit_view(id, width); + self + } + + pub fn add_field(&mut self, view: V) { + self.layout.add_field(view); + } + + pub fn field(mut self, view: V) -> Self { + self.add_field(view); + self + } + + pub fn set_id(&mut self, id: &'static str) { + self.id = id; + } + + pub fn id(mut self, id: &'static str) -> Self { + self.set_id(id); + self + } + + pub fn set_title(&mut self, title: &'static str) { + self.title = Some(title); + } + + pub fn title(mut self, title: &'static str) -> Self { + self.set_title(title); + self + } + + pub fn set_width(&mut self, width: usize) { + self.layout.set_width(width); + } + + pub fn width(mut self, width: usize) -> Self { + self.set_width(width); + self + } + + pub fn set_height(&mut self, height: usize) { + self.height = Some(height);; + } + + pub fn height(mut self, height: usize) -> Self { + self.set_height(height); + self + } + + pub fn build(self, ok_cb: F) -> impl View { + let content = self.layout.build() + .padded(2,2,1,2); + + let mut dialog = Dialog::around(content) + .dismiss_button("Cancel") + .button("Ok", ok_cb); + + if let Some(title) = self.title { + dialog.set_title(title); + } + + let height = self.height.unwrap_or(12); + + dialog.with_id(self.id) + .min_height(height) + } +} + +pub struct FieldLayout { + list: ListView, + message: String, + labels: Vec, + index: usize, + width: usize, +} + +#[allow(dead_code)] +impl FieldLayout { + const DEFAULT_WIDTH: usize = 48; + + pub fn new(labels: &[&str], message: &str) -> FieldLayout { + let maxlen = labels.iter().fold(0, |max, &s| { + if s.len() > max { s.len() } else { max } + }); + + let pad_label = |s: &&str| { + if s.is_empty() { + " ".repeat(maxlen + 2) + } else { + " ".repeat(maxlen - s.len()) + s + ": " + } + }; + + let labels = labels + .into_iter() + .map(pad_label) + .collect(); + + FieldLayout { + list: ListView::new(), + message: message.to_string(), + labels, + index: 0, + width: Self::DEFAULT_WIDTH, + } + } + + + pub fn add_edit_view(&mut self, id: &str, width: usize) { + self.add_field(EditView::new() + .style(ColorStyle::tertiary()) + .filler(" ") + .with_id(id) + .fixed_width(width)) + } + + pub fn edit_view(mut self, id: &str, width: usize) -> Self { + self.add_edit_view(id, width); + self + } + + pub fn add_field(&mut self, view: V) { + let field = LinearLayout::horizontal() + .child(view) + .child(DummyView); + + + let idx = self.index; + self.index += 1; + + if idx > 0 { + self.list.add_delimiter(); + } + self.list.add_child(self.labels[idx].as_str(), field); + } + + pub fn field(mut self, view: V) -> Self { + self.add_field(view); + self + } + + pub fn set_width(&mut self, width: usize) { + self.width = width; + } + + pub fn width(mut self, width: usize) -> Self { + self.set_width(width); + self + } + + pub fn build(self) -> impl View { + LinearLayout::vertical() + .child(TextView::new(self.message).fixed_width(self.width)) + .child(DummyView.fixed_height(2)) + .child(self.list) + .child(DummyView) + .scrollable() + } +} + + +pub trait DialogButtonAdapter: Finder+ViewWrapper { + + fn inner_id(&self) -> &'static str; + + fn call_on_dialog(&mut self, cb: F) -> R + where F: FnOnce(&mut Dialog) -> R + { + let id = self.inner_id(); + self.call_on_id(id, cb) + .expect(format!("failed call_on_id({})", id).as_str()) + } + + fn button_enabled(&mut self, button: usize) -> bool { + self.call_on_dialog(|d| { + d.buttons_mut() + .nth(button) + .map(|b| b.is_enabled()) + .unwrap_or(false) + }) + } + + fn set_button_enabled(&mut self, button: usize, enabled: bool) { + self.call_on_dialog(|d| { + d.buttons_mut() + .nth(button) + .map(|b| b.set_enabled(enabled)); + }) + } + + fn select_button(&mut self, button: usize) -> EventResult { + if self.button_enabled(button) { + self.call_on_dialog(|d| select_dialog_button_index(d, button)) + } + EventResult::Consumed(None) + } + + fn navigate_to_button(&mut self, idx: usize) -> EventResult { + if !self.button_enabled(idx) { + return EventResult::Ignored; + } + self.call_on_dialog(|d| { + d.take_focus(Direction::down()); + let mut current = d.buttons_len() - 1; + while current > idx { + d.on_event(Event::Key(Key::Left)); + current -= 1; + } + }); + EventResult::Consumed(None) + } + + fn handle_char_event(&mut self, button_order: &str, ch: char) -> EventResult { + if let Some(EventResult::Consumed(cb)) = self.with_view_mut(|v| v.on_event(Event::Char(ch))) { + EventResult::Consumed(cb) + } else if ch == 'T' || ch == '?' { + EventResult::Consumed(None) + } else if let Some(idx) = button_order.find(|c| c == ch) { + self.navigate_to_button(idx) + } else { + EventResult::Ignored + } + } + + fn handle_event(&mut self, button_order: &str, event: Event) -> EventResult { + match event { + Event::Char(ch) => self.handle_char_event(button_order, ch), + event => self.with_view_mut(|v| v.on_event(event)).unwrap() + } + } +} + +pub trait Padable: View + Sized { + fn padded(self, left: usize, right: usize, top: usize, bottom: usize) -> PaddedView { + PaddedView::new((left,right,top,bottom), self) + } +} +impl Padable for T {} + +pub trait Validatable: View+Finder+Sized { + fn validator ValidatorResult>(mut self, id: &str, cb: F) -> Self { + TextValidator::set_validator(&mut self, id, cb); + self + } +} + +impl Validatable for T {} + + +pub enum ValidatorResult { + Allow(Box), + Deny(Box), +} + +impl ValidatorResult { + pub fn create(ok: bool, f: F) -> Self + where F: 'static + Fn(&mut Cursive) { + if ok { + Self::allow_with(f) + } else { + Self::deny_with(f) + } + } + + pub fn allow_with(f: F) -> Self + where F: 'static + Fn(&mut Cursive) + { + ValidatorResult::Allow(Box::new(f)) + } + + pub fn deny_with(f: F) -> Self + where F: 'static + Fn(&mut Cursive) + { + ValidatorResult::Deny(Box::new(f)) + } + + fn process(self, siv: &mut Cursive) { + match self { + ValidatorResult::Allow(cb) => (cb)(siv), + ValidatorResult::Deny(cb) => (cb)(siv), + } + } + + fn deny_edit(&self) -> bool { + match self { + ValidatorResult::Allow(_) => false, + ValidatorResult::Deny(_) => true, + } + } + +} + +#[derive(Clone)] +pub struct TextValidator { + id: String, + is_valid: Rc ValidatorResult>>, +} + +impl TextValidator { + + pub fn set_validatorValidatorResult>(view: &mut V, id: &str, cb: F) { + let validator = TextValidator{ id: id.to_string(), is_valid: Rc::new(Box::new(cb)) }; + view.call_on_id(id, |v: &mut EditView| { + v.set_on_edit(move |s,content,cursor| { + let v = validator.clone(); + v.on_edit(s, content, cursor); + }); + }); + } + + fn on_edit(&self, siv: &mut Cursive, content: &str, cursor: usize) { + let result = (self.is_valid)(content); + if result.deny_edit() { + self.deny_edit(siv, cursor); + } + result.process(siv); + } + + fn deny_edit(&self, siv: &mut Cursive, cursor: usize) { + if cursor > 0 { + let callback = self.call_on_edit(siv, |v| { + v.set_cursor(cursor - 1); + v.remove(1) + }); + (callback)(siv); + } + } + + fn call_on_edit(&self, siv: &mut Cursive, f: F) -> R + where F: FnOnce(&mut EditView) -> R { + + siv.call_on_id(&self.id, f) + .expect(&format!("call_on_id({})", self.id)) + } + +} + diff --git a/citadel-realms/src/help.rs b/citadel-realms/src/help.rs new file mode 100644 index 0000000..d0785d7 --- /dev/null +++ b/citadel-realms/src/help.rs @@ -0,0 +1,102 @@ +use cursive::views::{DummyView, LinearLayout, TextView, PaddedView, OnEventView, Panel}; +use cursive::traits::{View,Boxable}; +use cursive::utils::markup::StyledString; +use cursive::theme::ColorStyle; +use cursive::align::HAlign; + +const REALM_SCREEN: usize = 1; + +pub fn help_panel(screen: usize) -> impl View { + + let content = if screen == REALM_SCREEN { + LinearLayout::vertical() + .child(help_header("Realms Commands")) + .child(DummyView) + .child(TextView::new(autostart_text())) + .child(DummyView) + + .child(help_item_autostart("Enter", "Set selected realm as Current.")) + .child(help_item_autostart("$ #", "Open user/root shell in selected realm.")) + .child(help_item_autostart("t", "Open terminal for selected realm.")) + .child(help_item_autostart("s", "Start/Stop selected realm.")) + .child(help_item("c", "Configure selected realm.")) + .child(help_item("d", "Delete selected realm.")) + .child(help_item("n", "Create a new realm.")) + .child(help_item("r", "Restart currently selected realm.")) + .child(help_item("u", "Open shell to update RealmFS image of selected realm.")) + .child(help_item(".", "Toggle display of system realms.")) + .child(DummyView) + } else { + LinearLayout::vertical() + .child(help_header("RealmsFS Image Commands")) + .child(DummyView) + .child(help_item("n", "Create new RealmFS as fork of selected image.")) + .child(help_item("s", "Seal selected RealmFS image.")) + .child(help_item("u", "Open shell to update selected RealmFS image.")) + .child(help_item(".", "Toggle display of system RealmFS images.")) + .child(DummyView) + } + + .child(help_header("Global Commands")) + .child(DummyView) + .child(help_item("Space", "Toggle between Realms and RealmFS views.")) + .child(help_item("q", "Exit application.")) + .child(help_item("l", "Toggle visibility of log panel.")) + .child(help_item("L", "Display full sized log view.")) + .child(help_item("T", "Select a UI color theme.")) + .child(DummyView) + .child(TextView::new(footer_text())); + + + let content = PaddedView::new((2,2,1,1), content); + let panel = Panel::new(content) + .title("Help"); + + OnEventView::new(panel) + .on_pre_event('?', |s| { s.pop_layer(); }) + .on_pre_event('h', |s| { s.pop_layer(); }) +} + +fn autostart_text() -> StyledString { + let mut text = StyledString::styled("[", ColorStyle::tertiary()); + text.append(autostart_icon()); + text.append_styled("] Start Realm if not currently running", ColorStyle::tertiary()); + text +} + +fn footer_text() -> StyledString { + StyledString::styled("'q' or ESC to close help panel", ColorStyle::tertiary()) +} + +fn autostart_icon() -> StyledString { + StyledString::styled("*", ColorStyle::title_primary()) +} + +fn help_item_autostart(keys: &str, help: &str) -> impl View { + _help_item(keys, help, true) +} + +fn help_item(keys: &str, help: &str) -> impl View { + _help_item(keys, help, false) +} + +fn _help_item(keys: &str, help: &str, start: bool) -> impl View { + let keys = StyledString::styled(keys, ColorStyle::secondary()); + let mut text = if start { + autostart_icon() + } else { + StyledString::plain(" ") + }; + text.append_plain(" "); + text.append_plain(help); + + LinearLayout::horizontal() + .child(TextView::new(keys).h_align(HAlign::Right).fixed_width(8)) + .child(DummyView.fixed_width(4)) + .child(TextView::new(text)) +} + +fn help_header(text: &str) -> impl View { + let text = StyledString::styled(text, ColorStyle::title_primary()); + TextView::new(text).h_align(HAlign::Left) +} diff --git a/citadel-realms/src/item_list.rs b/citadel-realms/src/item_list.rs new file mode 100644 index 0000000..979e0cf --- /dev/null +++ b/citadel-realms/src/item_list.rs @@ -0,0 +1,395 @@ +use std::ops::Deref; +use cursive::{Vec2, Printer, Cursive}; +use cursive::event::{EventResult, Event, Key}; +use cursive::views::{TextContent, Panel, TextView, LinearLayout}; +use cursive::traits::{View,Identifiable,Boxable}; +use cursive::direction::Direction; +use cursive::utils::markup::StyledString; +use cursive::theme::{Style, PaletteColor, Effect, ColorStyle}; +use std::rc::Rc; +use std::cell::RefCell; + + +pub struct Selector { + items: Vec, + current: usize, +} + +impl Deref for Selector { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.items[self.current] + } +} + +impl Selector{ + + fn from_vec(items: Vec) -> Self { + Selector { + items, + current: 0, + } + } + + fn set(&mut self, idx: usize) { + if idx > self.max_idx() { + self.current = self.max_idx(); + } else { + self.current = idx; + } + } + + fn get(&self, idx: usize) -> &T { + &self.items[idx] + } + + fn find

(&self, pred: P) -> Option + where P: Fn(&T) -> bool + { + self.items.iter() + .enumerate() + .find(|(_,elem)| pred(elem)) + .map(|(idx,_)| idx) + } + + fn len(&self) -> usize { + self.items.len() + } + + pub fn load_items(&mut self, items: Vec) { + self.items = items; + self.current = 0; + } + + pub fn load_and_keep_selection

(&mut self, items: Vec, pred: P) + where P: Fn(&T,&T) -> bool + { + let old_item = self.clone(); + self.load_items(items); + self.current = self.find(|it| pred(&old_item, it)).unwrap_or(0); + } + + fn up(&mut self, n: usize) { + self.set(self.current.saturating_sub(n)); + } + + fn max_idx(&self) -> usize { + if self.items.is_empty() { + 0 + } else { + self.items.len() - 1 + } + } + + fn down(&mut self, n: usize) { + self.set(self.current + n); + } + + fn current_item(&self) -> Option<&T> { + if self.items.is_empty() { + None + } else { + Some(self.get(self.current)) + } + } + + fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +pub trait ItemListContent { + fn items(&self) -> Vec; + + fn reload(&self, selector: &mut Selector) { + selector.load_items(self.items()); + } + + fn draw_item(&self, width: usize, printer: &Printer, item: &T, selected: bool); + + fn update_info(&mut self, item: &T, state: Rc); + + fn on_event(&mut self, item: Option<&T>, event: Event) -> EventResult; +} + +pub struct ItemList { + selector: Selector, + last_size: Vec2, + info_state: Rc, + content: Box>, +} + +impl ItemList { + + pub fn call_reload(id: &str, s: &mut Cursive) { + s.call_on_id(id, |v: &mut ItemList| v.reload_items()); + } + + pub fn call_update_info(id: &str, s: &mut Cursive) { + Self::call(id, s, |v| v.update_info()); + } + + pub fn call(id: &str, s: &mut Cursive, f: F) -> R + where F: FnOnce(&mut ItemList) -> R + { + s.call_on_id(id, |v: &mut ItemList| f(v)) + .expect(&format!("ItemList::call_on_id({})", id)) + + } + + pub fn create(id: &'static str, title: &str, content: C) -> impl View + where C: ItemListContent + 'static + { + + let list = ItemList::new(content); + let text = TextView::new_with_content(list.info_content()); + + let left = Panel::new(list.with_id(id)) + .title(title) + .min_width(30); + + let right = Panel::new(text).full_width(); + + LinearLayout::horizontal() + .child(left) + .child(right) + .full_height() + } + + + pub fn new(content: C) -> Self + where C: ItemListContent + 'static + { + let selector = Selector::from_vec(content.items()); + let last_size = Vec2::zero(); + let info_state = ItemRenderState::create(); + let content = Box::new(content); + let mut list = ItemList { selector, info_state, last_size, content }; + list.update_info(); + list + } + + pub fn info_content(&self) -> TextContent { + self.info_state.content() + } + + pub fn reload_items(&mut self) { + self.content.reload(&mut self.selector); + self.update_info(); + } + + pub fn selected_item(&self) -> &T { + &self.selector + } + + fn selection_up(&mut self) -> EventResult { + self.selector.up(1); + self.update_info(); + EventResult::Consumed(None) + } + + fn selection_down(&mut self) -> EventResult { + self.selector.down(1); + self.update_info(); + EventResult::Consumed(None) + } + + pub fn update_info(&mut self) { + self.info_state.clear(); + if !self.selector.is_empty() { + self.content.update_info(&self.selector, self.info_state.clone()); + } + } + + fn draw_item_idx(&self, printer: &Printer, idx: usize) { + let item = self.selector.get(idx); + let selected = idx == self.selector.current; + printer.offset((0,idx)).with_selection(selected, |printer| { + self.content.draw_item(self.last_size.x, printer, item, selected); + }); + } +} + +impl View for ItemList { + + fn draw(&self, printer: &Printer) { + for i in 0..self.selector.len() { + self.draw_item_idx(printer, i); + } + } + + fn layout(&mut self, size: Vec2) { + self.last_size = size; + } + + fn on_event(&mut self, event: Event) -> EventResult { + match event { + Event::Key(Key::Up) | Event::Char('k') => self.selection_up(), + Event::Key(Key::Down) | Event::Char('j') => self.selection_down(), + ev => self.content.on_event(self.selector.current_item(), ev), + } + } + + fn take_focus(&mut self, _source: Direction) -> bool { + true + } +} + + +pub struct ItemRenderState { + inner: RefCell, +} + +struct Inner { + content: TextContent, + styles: Vec