diff --git a/README.md b/README.md index a78de7d..36bd47f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,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: *) @@ -200,3 +203,20 @@ 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). + +| 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` | diff --git a/contrib/man/tuigreet-1.scd b/contrib/man/tuigreet-1.scd index afce7b1..b0e5e06 100644 --- a/contrib/man/tuigreet-1.scd +++ b/contrib/man/tuigreet-1.scd @@ -80,6 +80,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/src/greeter.rs b/src/greeter.rs index c4c7f7c..d857592 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, @@ -142,6 +142,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. @@ -363,6 +365,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"); @@ -413,6 +416,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 98c46f0..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("F2"), - status_value(fl!("action_command")), - status_label("F3"), - status_value(fl!("action_session")), - status_label("F12"), - 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 e752c21..f514ae9 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 { @@ -82,7 +92,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box Result<(u16, u16), Box