diff --git a/README.md b/README.md index a004e99..afab5f8 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ Options: minimum UID to display in the user selection menu --user-menu-max-uid UID maximum UID to display in the user selection menu + --theme SPEC + Add visual feedback when typing secrets, as one asterisk character for every + keystroke. By default, no feedback is given at all. --asterisks display asterisks when a secret is typed --asterisks-char CHARS characters to be used to redact secrets (default: *) @@ -205,3 +208,26 @@ Optionally, a user can be selected from a menu instead of typing out their name, * A user-provided value, through `--user-menu-min-uid` or `--user-menu-max-uid`; * **Or**, the available values for `UID_MIN` or `UID_MAX` from `/etc/login.defs`; * **Or**, hardcoded `1000` for minimum UID and `60000` for maximum UID. + +### Theming + +A theme specification can be given through the `--theme` argument to control some of the colors used to draw the UI. This specification string must have the following format: `component1=color;component2=color[;...]` where the component is one of the value listed in the table below, and the color is a valid ANSI color name as listed [here](https://github.com/ratatui-org/ratatui/blob/main/src/style/color.rs#L15). + +Please note that we can only render colors as supported by the running terminal. In the case of the Linux virtual console, those colors might not look as good as one may think. Your mileage may vary. + +| Component name | Description | +| -------------- | ---------------------------------------------------------------------------------- | +| text | Base text color other than those specified below | +| time | Color of the date and time. If unspecified, falls back to `text` | +| container | Background color for the centered containers used throughout the app | +| border | Color of the borders of those containers | +| title | Color of the containers' titles. If unspecified, falls back to `border` | +| greet | Color of the issue of greeting message. If unspecified, falls back to `text` | +| prompt | Color of the prompt ("Username:", etc.) | +| input | Color of user input feedback | +| action | Color of the actions displayed at the bottom of the screen | +| button | Color of the keybindings for those actions. If unspecified, falls back to `action` | + +Below is a screenshot of the greeter with the following theme applied: `border=magenta;text=cyan;prompt=green;time=red;action=blue;button=yellow;container=black;input=red`: + +![Screenshot of tuigreet](https://github.com/apognu/tuigreet/blob/master/contrib/screenshot-themed.png) diff --git a/contrib/man/tuigreet-1.scd b/contrib/man/tuigreet-1.scd index d0a238d..ca517f4 100644 --- a/contrib/man/tuigreet-1.scd +++ b/contrib/man/tuigreet-1.scd @@ -84,6 +84,10 @@ tuigreet - A graphical console greeter for greetd *--remember-user-session* Remember the last opened session, per user (requires *--remember*). +*--theme SPEC* + Define colors to be used to draw the UI components. You can find the proper + syntax in the project's README. + *--asterisks* Add visual feedback when typing secrets, as one asterisk character for every keystroke. By default, no feedback is given at all. diff --git a/contrib/screenshot-themed.png b/contrib/screenshot-themed.png new file mode 100644 index 0000000..1c212df Binary files /dev/null and b/contrib/screenshot-themed.png differ diff --git a/src/greeter.rs b/src/greeter.rs index 0ec0f36..85d846d 100644 --- a/src/greeter.rs +++ b/src/greeter.rs @@ -27,7 +27,7 @@ use crate::{ }, power::PowerOption, ui::{ - common::{masked::MaskedString, menu::Menu}, + common::{masked::MaskedString, menu::Menu, style::Theme}, power::Power, sessions::{Session, SessionSource, SessionType}, users::User, @@ -147,6 +147,8 @@ pub struct Greeter { // Whether last launched session for the current user should be remembered. pub remember_user_session: bool, + // Style object for the terminal UI + pub theme: Theme, // Greeting message (MOTD) to use to welcome the user. pub greeting: Option, // Transaction message to show to the user. @@ -376,6 +378,7 @@ impl Greeter { opts.optflag("", "user-menu", "allow graphical selection of users from a menu"); opts.optopt("", "user-menu-min-uid", "minimum UID to display in the user selection menu", "UID"); opts.optopt("", "user-menu-max-uid", "maximum UID to display in the user selection menu", "UID"); + opts.optopt("", "theme", "define the application theme colors", "THEME"); opts.optflag("", "asterisks", "display asterisks when a secret is typed"); opts.optopt("", "asterisks-char", "characters to be used to redact secrets (default: *)", "CHARS"); opts.optopt("", "window-padding", "padding inside the terminal area (default: 0)", "PADDING"); @@ -439,6 +442,12 @@ impl Greeter { process::exit(1); } + if self.config().opt_present("theme") { + if let Some(spec) = self.config().opt_str("theme") { + self.theme = Theme::parse(spec.as_str()); + } + } + if self.config().opt_present("asterisks") { let asterisk = if let Some(value) = self.config().opt_str("asterisks-char") { if value.chars().count() < 1 { diff --git a/src/ui/command.rs b/src/ui/command.rs index 3fc51d1..d0ae269 100644 --- a/src/ui/command.rs +++ b/src/ui/command.rs @@ -12,7 +12,11 @@ use crate::{ Greeter, }; +use super::common::style::Themed; + pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { + let theme = &greeter.theme; + let size = f.size(); let (x, y, width, height) = get_rect_bounds(greeter, size, 0); @@ -21,7 +25,13 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box Result<(u16, u16), Box String; } @@ -34,13 +36,21 @@ where T: MenuItem, { pub fn draw(&self, greeter: &Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { + let theme = &greeter.theme; + let size = f.size(); let (x, y, width, height) = get_rect_bounds(greeter, size, self.options.len()); let container = Rect::new(x, y, width, height); let title = Span::from(titleize(&self.title)); - let block = Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Plain); + let block = Block::default() + .title(title) + .title_style(theme.of(&[Themed::Title])) + .style(theme.of(&[Themed::Container])) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(theme.of(&[Themed::Border])); for (index, option) in self.options.iter().enumerate() { let name = option.format(); diff --git a/src/ui/common/mod.rs b/src/ui/common/mod.rs index 9713b6c..733ce5f 100644 --- a/src/ui/common/mod.rs +++ b/src/ui/common/mod.rs @@ -1,2 +1,3 @@ pub mod masked; pub mod menu; +pub mod style; diff --git a/src/ui/common/style.rs b/src/ui/common/style.rs new file mode 100644 index 0000000..8f24c98 --- /dev/null +++ b/src/ui/common/style.rs @@ -0,0 +1,108 @@ +use std::str::FromStr; + +use tui::style::{Color, Style}; + +#[derive(Clone)] +enum Component { + Bg, + Fg, +} + +pub enum Themed { + Container, + Time, + Text, + Border, + Title, + Greet, + Prompt, + Input, + Action, + ActionButton, +} + +#[derive(Default)] +pub struct Theme { + container: Option<(Component, Color)>, + time: Option<(Component, Color)>, + text: Option<(Component, Color)>, + border: Option<(Component, Color)>, + title: Option<(Component, Color)>, + greet: Option<(Component, Color)>, + prompt: Option<(Component, Color)>, + input: Option<(Component, Color)>, + action: Option<(Component, Color)>, + button: Option<(Component, Color)>, +} + +impl Theme { + pub fn parse(spec: &str) -> Theme { + use Component::*; + + let directives = spec.split(';').filter_map(|directive| directive.split_once('=')); + let mut style = Theme::default(); + + for (key, value) in directives { + if let Ok(color) = Color::from_str(value) { + match key { + "container" => style.container = Some((Bg, color)), + "time" => style.time = Some((Fg, color)), + "text" => style.text = Some((Fg, color)), + "border" => style.border = Some((Fg, color)), + "title" => style.title = Some((Fg, color)), + "greet" => style.greet = Some((Fg, color)), + "prompt" => style.prompt = Some((Fg, color)), + "input" => style.input = Some((Fg, color)), + "action" => style.action = Some((Fg, color)), + "button" => style.button = Some((Fg, color)), + _ => {} + } + } + } + + if style.time.is_none() { + style.time = style.text.clone(); + } + if style.greet.is_none() { + style.greet = style.text.clone(); + } + if style.title.is_none() { + style.title = style.border.clone(); + } + if style.button.is_none() { + style.button = style.action.clone(); + } + + style + } + + pub fn of(&self, targets: &[Themed]) -> Style { + targets.iter().fold(Style::default(), |style, target| self.apply(style, target)) + } + + fn apply(&self, style: Style, target: &Themed) -> Style { + use Themed::*; + + let color = match target { + Container => &self.container, + Time => &self.time, + Text => &self.text, + Border => &self.border, + Title => &self.title, + Greet => &self.greet, + Prompt => &self.prompt, + Input => &self.input, + Action => &self.action, + ActionButton => &self.button, + }; + + match color { + Some((component, color)) => match component { + Component::Fg => style.fg(*color), + Component::Bg => style.bg(*color), + }, + + None => style, + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8c2287f..e90f143 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,7 +19,7 @@ use tokio::sync::RwLock; use tui::{ backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout}, - style::{Modifier, Style}, + style::Modifier, text::{Line, Span}, widgets::Paragraph, Frame as CrosstermFrame, Terminal, @@ -31,6 +31,7 @@ use crate::{ Greeter, Mode, }; +use self::common::style::{Theme, Themed}; pub use self::i18n::MESSAGES; const TITLEBAR_INDEX: usize = 1; @@ -47,6 +48,8 @@ pub async fn draw(greeter: Arc>, terminal: &mut Term) -> Result< let hide_cursor = should_hide_cursor(&greeter); terminal.draw(|f| { + let theme = &greeter.theme; + let size = f.size(); let chunks = Layout::default() .constraints( @@ -63,7 +66,7 @@ pub async fn draw(greeter: Arc>, terminal: &mut Term) -> Result< if greeter.config().opt_present("time") { let time_text = Span::from(get_time(&greeter)); - let time = Paragraph::new(time_text).alignment(Alignment::Center); + let time = Paragraph::new(time_text).alignment(Alignment::Center).style(theme.of(&[Themed::Time])); f.render_widget(time, chunks[TITLEBAR_INDEX]); } @@ -86,23 +89,23 @@ pub async fn draw(greeter: Arc>, terminal: &mut Term) -> Result< let command = greeter.session_source.label(&greeter).unwrap_or("-"); let status_left_text = Line::from(vec![ - status_label("ESC"), - status_value(fl!("action_reset")), - status_label(&format!("F{}", greeter.kb_command)), - status_value(fl!("action_command")), - status_label(&format!("F{}", greeter.kb_sessions)), - status_value(fl!("action_session")), - status_label(&format!("F{}", greeter.kb_power)), - status_value(fl!("action_power")), - status_label(fl!("status_command")), - status_value(command), + status_label(theme, "ESC"), + status_value(theme, fl!("action_reset")), + status_label(theme, "F2"), + status_value(theme, fl!("action_command")), + status_label(theme, "F3"), + status_value(theme, fl!("action_session")), + status_label(theme, "F12"), + status_value(theme, fl!("action_power")), + status_label(theme, fl!("status_command")), + status_value(theme, command), ]); let status_left = Paragraph::new(status_left_text); f.render_widget(status_left, status_chunks[STATUSBAR_LEFT_INDEX]); if capslock_status() { - let status_right_text = status_label(fl!("status_caps")); + let status_right_text = status_label(theme, fl!("status_caps")); let status_right = Paragraph::new(status_right_text).alignment(Alignment::Right); f.render_widget(status_right, status_chunks[STATUSBAR_RIGHT_INDEX]); @@ -138,26 +141,26 @@ fn get_time(greeter: &Greeter) -> String { Local::now().format_localized(&format, greeter.locale).to_string() } -fn status_label<'s, S>(text: S) -> Span<'s> +fn status_label<'s, S>(theme: &Theme, text: S) -> Span<'s> where S: Into, { - Span::styled(text.into(), Style::default().add_modifier(Modifier::REVERSED)) + Span::styled(text.into(), theme.of(&[Themed::ActionButton]).add_modifier(Modifier::REVERSED)) } -fn status_value<'s, S>(text: S) -> Span<'s> +fn status_value<'s, S>(theme: &Theme, text: S) -> Span<'s> where S: Into, { - Span::from(titleize(&text.into())) + Span::from(titleize(&text.into())).style(theme.of(&[Themed::Action])) } -fn prompt_value<'s, S>(text: Option) -> Span<'s> +fn prompt_value<'s, S>(theme: &Theme, text: Option) -> Span<'s> where S: Into, { match text { - Some(text) => Span::styled(text.into(), Style::default().add_modifier(Modifier::BOLD)), + Some(text) => Span::styled(text.into(), theme.of(&[Themed::Prompt]).add_modifier(Modifier::BOLD)), None => Span::from(""), } } diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 8edbc07..53400c5 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -13,11 +13,15 @@ use crate::{ Greeter, Mode, SecretDisplay, }; +use super::common::style::Themed; + const GREETING_INDEX: usize = 0; const USERNAME_INDEX: usize = 1; const ANSWER_INDEX: usize = 3; pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { + let theme = &greeter.theme; + let size = f.size(); let (x, y, width, height) = get_rect_bounds(greeter, size, 0); @@ -28,7 +32,13 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box Result<(u16, u16), Box Result<(u16, u16), Box { @@ -84,7 +94,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box Result<(u16, u16), Box