Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement text styling across the UI. #133

Merged
merged 1 commit into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: *)
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions contrib/man/tuigreet-1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file added contrib/screenshot-themed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 @@ -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.
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
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;
108 changes: 108 additions & 0 deletions src/ui/common/style.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
}
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(&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]);
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