mirror of
https://github.com/apognu/tuigreet.git
synced 2024-11-22 12:44:06 +03:00
Implement text styling across the UI.
This commit is contained in:
parent
6b4a41e7f7
commit
71cf19e233
26
README.md
26
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)
|
||||
|
@ -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.
|
||||
|
BIN
contrib/screenshot-themed.png
Normal file
BIN
contrib/screenshot-themed.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 196 KiB |
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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();
|
||||
|
@ -1,2 +1,3 @@
|
||||
pub mod masked;
|
||||
pub mod menu;
|
||||
pub mod style;
|
||||
|
108
src/ui/common/style.rs
Normal file
108
src/ui/common/style.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
@ -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(""),
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user