Skip to content

Commit

Permalink
Implement text styling across the UI.
Browse files Browse the repository at this point in the history
  • Loading branch information
apognu committed Apr 25, 2024
1 parent 2f4d5ef commit a3728cf
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 31 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: *)
Expand Down Expand Up @@ -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` |
4 changes: 4 additions & 0 deletions contrib/man/tuigreet-1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/greeter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
// Transaction message to show to the user.
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 14 additions & 4 deletions src/ui/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Error>> {
let theme = &greeter.theme;

let size = f.size();
let (x, y, width, height) = get_rect_bounds(greeter, size, 0);

Expand All @@ -21,7 +25,13 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
let container = Rect::new(x, y, width, height);
let frame = Rect::new(x + container_padding, y + container_padding, width - container_padding, height - container_padding);

let block = Block::default().title(titleize(&fl!("title_command"))).borders(Borders::ALL).border_type(BorderType::Plain);
let block = Block::default()
.title(titleize(&fl!("title_command")))
.title_style(theme.of(&[Themed::Title]))
.style(theme.of(&[Themed::Container]))
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(theme.of(&[Themed::Border]));

f.render_widget(block, container);

Expand All @@ -32,10 +42,10 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
let chunks = Layout::default().direction(Direction::Vertical).constraints(constraints.as_ref()).split(frame);
let cursor = chunks[0];

let command_label_text = prompt_value(Some(fl!("new_command")));
let command_label = Paragraph::new(command_label_text);
let command_label_text = prompt_value(theme, Some(fl!("new_command")));
let command_label = Paragraph::new(command_label_text).style(theme.of(&[Themed::Prompt]));
let command_value_text = Span::from(greeter.buffer.clone());
let command_value = Paragraph::new(command_value_text);
let command_value = Paragraph::new(command_value_text).style(theme.of(&[Themed::Input]));

f.render_widget(command_label, chunks[0]);
f.render_widget(
Expand Down
12 changes: 11 additions & 1 deletion src/ui/common/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use crate::{
Greeter,
};

use super::style::Themed;

pub trait MenuItem {
fn format(&self) -> String;
}
Expand All @@ -34,13 +36,21 @@ where
T: MenuItem,
{
pub fn draw(&self, greeter: &Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn Error>> {
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();
Expand Down
1 change: 1 addition & 0 deletions src/ui/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod masked;
pub mod menu;
pub mod style;
114 changes: 114 additions & 0 deletions src/ui/common/style.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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 {
let mut style = Style::default();

for target in targets {
style = self.apply(style, target);
}

style
}

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,
}
}
}
41 changes: 22 additions & 19 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +31,7 @@ use crate::{
Greeter, Mode,
};

use self::common::style::{Theme, Themed};
pub use self::i18n::MESSAGES;

const TITLEBAR_INDEX: usize = 1;
Expand All @@ -47,6 +48,8 @@ pub async fn draw(greeter: Arc<RwLock<Greeter>>, 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(
Expand All @@ -63,7 +66,7 @@ pub async fn draw(greeter: Arc<RwLock<Greeter>>, 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]);
}
Expand All @@ -86,23 +89,23 @@ pub async fn draw(greeter: Arc<RwLock<Greeter>>, 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]);
Expand Down Expand Up @@ -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<String>,
{
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<String>,
{
Span::from(titleize(&text.into()))
Span::from(titleize(&text.into())).style(theme.of(&[Themed::Action]))
}

fn prompt_value<'s, S>(text: Option<S>) -> Span<'s>
fn prompt_value<'s, S>(theme: &Theme, text: Option<S>) -> Span<'s>
where
S: Into<String>,
{
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(""),
}
}
Loading

0 comments on commit a3728cf

Please sign in to comment.