Implement text styling across the UI.

This commit is contained in:
Antoine POPINEAU 2024-04-25 09:38:10 +02:00 committed by Antoine POPINEAU
parent 6b4a41e7f7
commit 71cf19e233
10 changed files with 212 additions and 31 deletions

View File

@ -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)

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@ -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<String>,
// 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 {

View File

@ -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);
@ -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);
@ -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(

View File

@ -15,6 +15,8 @@ use crate::{
Greeter,
};
use super::style::Themed;
pub trait MenuItem {
fn format(&self) -> String;
}
@ -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();

View File

@ -1,2 +1,3 @@
pub mod masked;
pub mod menu;
pub mod style;

108
src/ui/common/style.rs Normal file
View File

@ -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,
}
}
}

View File

@ -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<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(
@ -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]);
}
@ -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(&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<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(""),
}
}

View File

@ -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<dyn Error>> {
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<dyn
let frame = Rect::new(x + container_padding, y + container_padding, width - (2 * container_padding), height - (2 * container_padding));
let hostname = Span::from(titleize(&fl!("title_authenticate", hostname = get_hostname())));
let block = Block::default().title(hostname).borders(Borders::ALL).border_type(BorderType::Plain);
let block = Block::default()
.title(hostname)
.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);
@ -49,7 +59,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
if let Some(greeting) = &greeting {
let greeting_text = greeting.trim_end();
let greeting_label = Paragraph::new(greeting_text).alignment(Alignment::Center);
let greeting_label = Paragraph::new(greeting_text).alignment(Alignment::Center).style(theme.of(&[Themed::Greet]));
f.render_widget(greeting_label, chunks[GREETING_INDEX]);
}
@ -59,14 +69,14 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
Paragraph::new(prompt_text).alignment(Alignment::Center)
} else {
let username_text = prompt_value(Some(fl!("username")));
let username_text = prompt_value(theme, Some(fl!("username")));
Paragraph::new(username_text)
};
let username = greeter.username.get();
let username_value_text = Span::from(username);
let username_value = Paragraph::new(username_value_text);
let username_value = Paragraph::new(username_value_text).style(theme.of(&[Themed::Input]));
match greeter.mode {
Mode::Username | Mode::Password | Mode::Action => {
@ -84,7 +94,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
);
}
let answer_text = if greeter.working { Span::from(fl!("wait")) } else { prompt_value(greeter.prompt.as_ref()) };
let answer_text = if greeter.working { Span::from(fl!("wait")) } else { prompt_value(theme, greeter.prompt.as_ref()) };
let answer_label = Paragraph::new(answer_text);
@ -107,7 +117,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
};
let answer_value_text = Span::from(value);
let answer_value = Paragraph::new(answer_value_text);
let answer_value = Paragraph::new(answer_value_text).style(theme.of(&[Themed::Input]));
f.render_widget(
answer_value,