refactor: split the TUI and refresh project docs

Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds.

Checks: make check; make integration-test; go test ./... (legacy/go).
This commit is contained in:
2026-10-05 21:37:15 +05:30
parent de8843471b
commit f2a885d621
33 changed files with 2866 additions and 2173 deletions

View File

@@ -0,0 +1,264 @@
//! Login screen state and orchestration.
use std::{
sync::mpsc::Receiver,
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use zeroize::Zeroizing;
use latchd_auth::{AuthService, AuthTicket};
use latchd_core::{
accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
config::Config,
error::{Error, ResultExt},
preferences::{self, LastUsedPreferences},
session::{self, SessionDescriptor},
};
use crate::background::Background;
mod auth;
mod discovery;
mod input;
mod render;
#[cfg(test)]
mod tests;
const MIN_WIDTH: u16 = 60;
const MIN_HEIGHT: u16 = 16;
const MAX_INPUT: usize = 256;
fn unknown_account_status() -> AccountStatus {
AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
}
}
pub struct LoginSelection {
pub ticket: AuthTicket,
pub session: SessionDescriptor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Sessions,
Username,
Password,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FilterTarget {
Sessions,
Accounts,
}
#[derive(Debug, Clone)]
struct FilterState {
target: FilterTarget,
query: String,
}
type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
#[derive(Debug)]
enum AuthState {
Idle,
Pending(Receiver<Result<AuthTicket, Error>>),
PasswordChangePending(Receiver<Result<(), Error>>),
Failed,
}
enum AuthCompletion {
Login(Result<AuthTicket, Error>),
PasswordChange(Result<(), Error>),
}
#[derive(Debug, Clone, Copy)]
enum NoticeLevel {
Info,
Warning,
}
#[derive(Debug)]
struct Notice {
message: String,
level: NoticeLevel,
expires: Instant,
}
struct App {
config: Config,
auth: AuthService,
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
account_statuses: Vec<AccountStatus>,
account_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>,
selected: usize,
focus: Focus,
username: String,
password: Zeroizing<String>,
pending_password: Zeroizing<Vec<u8>>,
pending_new_password: Zeroizing<String>,
password_change_mode: bool,
status: String,
auth_state: AuthState,
auth_failures: u32,
auth_blocked_until: Option<Instant>,
show_help: bool,
power_confirmation: Option<usize>,
should_quit: bool,
ascii: bool,
no_color: bool,
background: Background,
notice: Option<Notice>,
animation_frame: usize,
reduced_motion: bool,
filter: Option<FilterState>,
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
preferences: LastUsedPreferences,
preference_path: Option<std::path::PathBuf>,
}
impl App {
fn new(
config: Config,
auth: AuthService,
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
background: Background,
) -> Self {
let username = accounts
.first()
.map(|account| account.username.clone())
.unwrap_or_default();
let account_index = (!accounts.is_empty()).then_some(0);
let reduced_motion = config.visual.reduced_motion;
Self {
config,
auth,
preview,
sessions,
account_statuses: vec![unknown_account_status(); accounts.len()],
accounts,
account_index,
faillock_receiver: None,
faillock_status: None,
selected: 0,
focus: Focus::Username,
username,
password: Zeroizing::new(String::new()),
pending_password: Zeroizing::new(Vec::new()),
pending_new_password: Zeroizing::new(String::new()),
password_change_mode: false,
status: "Enter your username".into(),
auth_state: AuthState::Idle,
auth_failures: 0,
auth_blocked_until: None,
show_help: false,
power_confirmation: None,
should_quit: false,
ascii: std::env::var_os("LATCHD_ASCII").is_some(),
no_color: std::env::var_os("NO_COLOR").is_some(),
background,
notice: None,
animation_frame: 0,
reduced_motion,
filter: None,
refresh_receiver: None,
refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
preferences: LastUsedPreferences::default(),
preference_path: None,
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
loop {
self.poll_faillock();
self.poll_refresh();
let now = Instant::now();
if self.background.tick_if_due(now) {
self.animation_frame = self.animation_frame.wrapping_add(1);
}
if self
.notice
.as_ref()
.is_some_and(|notice| notice.expires <= now)
{
self.notice = None;
}
terminal
.draw(|frame| self.draw(frame))
.map_err(|error| Error::Terminal(error.to_string()))?;
if let Some(completion) = self.poll_auth() {
if let Some(selection) = self.handle_auth_completion(completion)? {
return Ok(Some(selection));
}
}
if self.should_quit {
return Ok(None);
}
if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? {
if let Event::Key(key) = event::read().io_context("read terminal event")? {
if key.kind == KeyEventKind::Press {
self.handle_key(key);
}
}
}
}
}
}
/// Run the login screen and restore the terminal on every return path.
pub fn run(
config: Config,
auth: AuthService,
preview: bool,
) -> Result<Option<LoginSelection>, Error> {
let sessions = session::discover(&config);
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
(Vec::new(), Vec::new())
};
let background = Background::load(&config.visual)?;
let preference_path = config
.environment_switcher
.remember_last_session
.then(|| config.environment_switcher.last_session_path.clone());
let preferences =
preference_path
.as_deref()
.map_or_else(LastUsedPreferences::default, |path| {
preferences::load(path).unwrap_or_else(|error| {
tracing::debug!(%error, "could not load last-session preferences");
LastUsedPreferences::default()
})
});
let mut app = App::new(config, auth, preview, sessions, accounts, background);
app.account_statuses = account_statuses;
app.account_index = (!app.accounts.is_empty()).then_some(0);
app.preferences = preferences;
app.preference_path = preference_path;
app.apply_preferred_session();
app.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
}

View File

@@ -0,0 +1,229 @@
use std::{
sync::mpsc,
thread,
time::{Duration, Instant},
};
use zeroize::Zeroizing;
use latchd_core::{
audit::{AuditAction, AuditEvent},
error::Error,
};
use super::{App, AuthCompletion, AuthState, Focus, LoginSelection, NoticeLevel};
impl App {
pub(super) fn poll_auth(&mut self) -> Option<AuthCompletion> {
match &self.auth_state {
AuthState::Pending(receiver) => match receiver.try_recv() {
Ok(result) => Some(AuthCompletion::Login(result)),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err(
Error::Worker("authentication task disconnected".into()),
))),
},
AuthState::PasswordChangePending(receiver) => match receiver.try_recv() {
Ok(result) => Some(AuthCompletion::PasswordChange(result)),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::PasswordChange(Err(
Error::Worker("password-change task disconnected".into()),
))),
},
AuthState::Idle | AuthState::Failed => None,
}
}
pub(super) fn handle_auth_completion(
&mut self,
completion: AuthCompletion,
) -> Result<Option<LoginSelection>, Error> {
match completion {
AuthCompletion::Login(Ok(ticket)) => {
self.audit(AuditEvent::succeeded(
AuditAction::Authentication,
&self.username,
));
self.auth_failures = 0;
self.auth_blocked_until = None;
self.pending_password.clear();
let selected = self
.sessions
.get(self.selected)
.cloned()
.ok_or_else(|| Error::Session("no session selected".into()))?;
self.remember_selected_session(&selected);
Ok(Some(LoginSelection {
ticket,
session: selected,
}))
}
AuthCompletion::Login(Err(error)) => {
let audit = AuditEvent::failed(AuditAction::Authentication, &self.username, &error);
self.audit(audit);
if error.requires_password_change() {
self.auth_state = AuthState::Idle;
self.password_change_mode = true;
self.password.clear();
self.auth_failures = 0;
self.auth_blocked_until = None;
self.focus = Focus::Password;
self.status = "PAM requires a new password; enter it and press Enter".into();
self.notify("Password change required by PAM", NoticeLevel::Warning);
} else {
self.auth_state = AuthState::Failed;
self.pending_password.clear();
self.password.clear();
self.auth_failures = self.auth_failures.saturating_add(1);
self.auth_blocked_until =
Some(Instant::now() + auth_backoff(self.auth_failures));
self.status = "Authentication failed".into();
self.notify("Authentication failed", NoticeLevel::Warning);
self.start_faillock_query();
}
Ok(None)
}
AuthCompletion::PasswordChange(Ok(())) => {
self.audit(AuditEvent::succeeded(
AuditAction::PasswordChange,
&self.username,
));
self.password = std::mem::replace(
&mut self.pending_new_password,
Zeroizing::new(String::new()),
);
self.pending_password.clear();
self.password_change_mode = false;
self.auth_state = AuthState::Idle;
self.status = "Password changed; authenticating…".into();
self.notify("Password changed; signing in", NoticeLevel::Info);
self.begin_authentication();
Ok(None)
}
AuthCompletion::PasswordChange(Err(error)) => {
self.audit(AuditEvent::failed(
AuditAction::PasswordChange,
&self.username,
&error,
));
self.auth_state = AuthState::Failed;
self.password_change_mode = false;
self.pending_password.clear();
self.pending_new_password.clear();
self.password.clear();
self.status = "PAM rejected the password change".into();
self.notify("Password change failed", NoticeLevel::Warning);
Ok(None)
}
}
}
pub(super) fn audit(&self, event: AuditEvent<'_>) {
tracing::info!(
target: "latchd.audit",
action = %event.action,
outcome = %event.outcome,
account = %event.account,
pam_reason = ?event.pam_reason,
"security event"
);
}
pub(super) fn begin_authentication(&mut self) {
if self.preview {
self.status = "Preview mode — authentication and session startup are disabled".into();
return;
}
if self.password_change_mode {
self.begin_password_change();
return;
}
if self.username.is_empty() || self.password.is_empty() {
self.status = "Username and password are required".into();
return;
}
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
self.status = format!(
"Authentication paused after failed attempts; try again in {}s",
remaining.as_secs().saturating_add(1)
);
return;
}
self.auth_blocked_until = None;
}
let username = self.username.clone();
let password = self.password.as_bytes().to_vec();
self.pending_password = Zeroizing::new(password.clone());
let service = self.config.pam_service.clone();
let auth = self.auth.clone();
let (sender, receiver) = mpsc::sync_channel(0);
let task = thread::Builder::new()
.name("latchd-auth-attempt".into())
.spawn(move || {
let result = auth.authenticate(username, password, service);
let _ = sender.send(result);
});
match task {
Ok(_) => {
self.audit(AuditEvent::started(
AuditAction::Authentication,
&self.username,
));
self.auth_state = AuthState::Pending(receiver);
self.status = "Authenticating…".into();
self.password.clear();
}
Err(error) => {
self.pending_password.clear();
self.status = format!("Could not start authentication: {error}");
}
}
}
pub(super) fn begin_password_change(&mut self) {
if self.pending_password.is_empty() {
self.password_change_mode = false;
self.status = "Password-change context expired; authenticate again".into();
return;
}
if self.password.is_empty() {
self.status = "A new password is required".into();
return;
}
let username = self.username.clone();
let current_password = self.pending_password.to_vec();
let new_password = self.password.as_bytes().to_vec();
self.pending_new_password = Zeroizing::new(self.password.as_str().to_owned());
let service = self.config.pam_service.clone();
let auth = self.auth.clone();
let (sender, receiver) = mpsc::sync_channel(0);
let task = thread::Builder::new()
.name("latchd-password-change".into())
.spawn(move || {
let result =
auth.change_password(username, current_password, new_password, service);
let _ = sender.send(result);
});
match task {
Ok(_) => {
self.audit(AuditEvent::started(
AuditAction::PasswordChange,
&self.username,
));
self.auth_state = AuthState::PasswordChangePending(receiver);
self.status = "Changing password through PAM…".into();
self.password.clear();
}
Err(error) => {
self.pending_new_password.clear();
self.status = format!("Could not start password change: {error}");
}
}
}
}
pub(super) fn auth_backoff(failures: u32) -> Duration {
let shift = failures.saturating_sub(1).min(4);
Duration::from_secs(1u64 << shift)
}

View File

@@ -0,0 +1,172 @@
use std::sync::mpsc;
use std::thread;
use latchd_core::{accounts, preferences, session, session::SessionDescriptor};
use super::{App, NoticeLevel, unknown_account_status};
impl App {
pub(super) fn apply_preferred_session(&mut self) {
if let Some(index) = self
.preferences
.preferred_index(&self.username, &self.sessions)
{
self.selected = index;
}
}
pub(super) fn remember_selected_session(&mut self, session: &SessionDescriptor) {
self.preferences.remember(&self.username, session);
let Some(path) = self.preference_path.clone() else {
return;
};
if let Err(error) = preferences::save(&path, &self.preferences) {
tracing::debug!(%error, "could not persist last-session preference");
}
}
pub(super) fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else {
return;
};
match receiver.try_recv() {
Ok(Ok(status)) => {
self.faillock_status = Some(status);
self.faillock_receiver = None;
if status.available {
if status.locked_out {
self.notify(
"Account has reached the advisory faillock threshold",
NoticeLevel::Warning,
);
} else if status.failures > 0 {
self.notify(
&format!("{} failed attempt(s) recorded", status.failures),
NoticeLevel::Info,
);
}
}
}
Ok(Err(error)) => {
tracing::debug!(%error, "faillock query failed");
self.faillock_receiver = None;
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None,
}
}
pub(super) fn poll_refresh(&mut self) {
let Some(receiver) = &self.refresh_receiver else {
return;
};
match receiver.try_recv() {
Ok((sessions, accounts, account_statuses)) => {
self.sessions = sessions;
self.accounts = accounts;
self.account_statuses = account_statuses;
self.refresh_receiver = None;
self.refreshing = false;
self.reconcile_selection();
self.status = format!(
"Discovery refreshed: {} session(s), {} account(s)",
self.sessions.len(),
self.accounts.len()
);
self.notify("Account and session discovery refreshed", NoticeLevel::Info);
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.refresh_receiver = None;
self.refreshing = false;
self.status = "Discovery refresh failed".into();
self.notify(
"Could not refresh account and session discovery",
NoticeLevel::Warning,
);
}
}
}
pub(super) fn start_refresh(&mut self) {
if self.refreshing {
self.status = "Discovery refresh is already running".into();
return;
}
let config = self.config.clone();
let (sender, receiver) = mpsc::sync_channel(1);
let task = thread::Builder::new()
.name("latchd-discovery".into())
.spawn(move || {
let sessions = session::discover(&config);
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
(Vec::new(), Vec::new())
};
let _ = sender.send((sessions, accounts, account_statuses));
});
match task {
Ok(_) => {
self.refresh_receiver = Some(receiver);
self.refreshing = true;
self.status = "Refreshing accounts and sessions…".into();
}
Err(error) => {
tracing::debug!(%error, "could not start discovery refresh");
self.status = format!("Could not start discovery refresh: {error}");
}
}
}
pub(super) fn reconcile_selection(&mut self) {
if self.sessions.is_empty() {
self.selected = 0;
} else if self.selected >= self.sessions.len() {
self.selected = self.sessions.len() - 1;
}
if self.accounts.is_empty() {
self.account_index = None;
self.account_statuses.clear();
} else if self.account_statuses.len() != self.accounts.len() {
self.account_statuses
.resize(self.accounts.len(), unknown_account_status());
} else if self
.account_index
.is_some_and(|index| index >= self.accounts.len())
{
self.account_index = Some(0);
}
if let Some(index) = self.account_index {
if let Some(account) = self.accounts.get(index) {
self.username = account.username.clone();
}
}
}
pub(super) fn start_faillock_query(&mut self) {
let username = self.username.clone();
if username.is_empty() {
self.faillock_receiver = None;
self.faillock_status = None;
return;
}
let config = self.config.accounts.clone();
let (sender, receiver) = mpsc::sync_channel(1);
self.faillock_receiver = Some(receiver);
let task = thread::Builder::new()
.name("latchd-faillock".into())
.spawn(move || {
let result = accounts::faillock_status(&username, &config);
let _ = sender.send(result);
});
if let Err(error) = task {
tracing::debug!(%error, "could not start faillock query");
self.faillock_receiver = None;
}
}
}

View File

@@ -0,0 +1,449 @@
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{App, AuthState, FilterState, FilterTarget, Focus, MAX_INPUT, Notice, NoticeLevel};
impl App {
pub(super) fn notify(&mut self, message: &str, level: NoticeLevel) {
if self.config.visual.notifications {
self.notice = Some(Notice {
message: message.to_owned(),
level,
expires: Instant::now() + Duration::from_secs(4),
});
}
}
pub(super) fn begin_filter(&mut self) {
let target = match self.focus {
Focus::Sessions => FilterTarget::Sessions,
Focus::Username => FilterTarget::Accounts,
Focus::Password => return,
};
self.filter = Some(FilterState {
target,
query: String::new(),
});
self.status = match target {
FilterTarget::Sessions => {
"Filter sessions: type text, Enter selects, Esc clears".into()
}
FilterTarget::Accounts => {
"Filter accounts: type text, Enter selects, Esc clears".into()
}
};
}
pub(super) fn filter_key(&mut self, key: KeyEvent) {
let Some(target) = self.filter.as_ref().map(|filter| filter.target) else {
return;
};
match key.code {
KeyCode::Esc => {
self.filter = None;
self.status = "Filter cleared".into();
}
KeyCode::Enter => self.accept_filter(),
KeyCode::Backspace => {
if let Some(filter) = &mut self.filter {
filter.query.pop();
}
self.update_filter_status();
}
KeyCode::Up | KeyCode::Left => match target {
FilterTarget::Sessions => self.previous_session(),
FilterTarget::Accounts => self.previous_account(),
},
KeyCode::Down | KeyCode::Right => match target {
FilterTarget::Sessions => self.next_session(),
FilterTarget::Accounts => self.next_account(),
},
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& self
.filter
.as_ref()
.is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) =>
{
if let Some(filter) = &mut self.filter {
filter.query.push(character);
}
self.update_filter_status();
}
_ => {}
}
}
pub(super) fn update_filter_status(&mut self) {
let Some(filter) = &self.filter else {
return;
};
let count = match filter.target {
FilterTarget::Sessions => self.matching_session_indices().len(),
FilterTarget::Accounts => self.matching_account_indices().len(),
};
self.status = format!(
"Filter {}: {} match(es) — Enter selects, Esc clears",
match filter.target {
FilterTarget::Sessions => "sessions",
FilterTarget::Accounts => "accounts",
},
count
);
}
pub(super) fn accept_filter(&mut self) {
let Some(filter) = self.filter.take() else {
return;
};
match filter.target {
FilterTarget::Sessions => {
let index = self
.sessions
.iter()
.enumerate()
.find(|(_, session)| matches_filter(&session.title, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.selected = index;
self.status = format!("Selected session {}", self.sessions[index].title);
} else {
self.status = "No matching sessions".into();
}
}
FilterTarget::Accounts => {
let index = self
.accounts
.iter()
.enumerate()
.find(|(_, account)| matches_filter(&account.username, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.select_account_index(index);
} else {
self.status = "No matching accounts".into();
}
}
}
}
pub(super) fn matching_session_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Sessions)
.map_or("", |filter| filter.query.as_str());
self.sessions
.iter()
.enumerate()
.filter(|(_, session)| matches_filter(&session.title, query))
.map(|(index, _)| index)
.collect()
}
pub(super) fn matching_account_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Accounts)
.map_or("", |filter| filter.query.as_str());
self.accounts
.iter()
.enumerate()
.filter(|(_, account)| matches_filter(&account.username, query))
.map(|(index, _)| index)
.collect()
}
pub(super) fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
if matches!(
self.auth_state,
AuthState::Pending(_) | AuthState::PasswordChangePending(_)
) {
if key.code == KeyCode::Esc {
self.status = "Authentication is still in progress".into();
}
return;
}
if self.show_help {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
self.show_help = false;
}
return;
}
if self.filter.is_some() {
self.filter_key(key);
return;
}
if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('n') => {
self.next_account();
return;
}
KeyCode::Char('p') => {
self.previous_account();
return;
}
KeyCode::Char('b') => {
let enabled = self.background.toggle_enabled();
self.notify(
if enabled {
"Ambient background enabled"
} else {
"Ambient background disabled"
},
NoticeLevel::Info,
);
return;
}
KeyCode::Char('m') => {
let reduced = self.background.toggle_motion();
self.reduced_motion = reduced;
self.notify(
if reduced {
"Reduced motion enabled"
} else {
"Reduced motion disabled"
},
NoticeLevel::Info,
);
return;
}
KeyCode::Char('r') => {
self.start_refresh();
return;
}
_ => {}
}
}
if let Some(index) = self.power_confirmation {
if key.code == KeyCode::Enter {
self.run_power_action(index);
} else if key.code == KeyCode::Esc {
self.power_confirmation = None;
self.status = "Power action cancelled".into();
}
return;
}
match key.code {
KeyCode::Char('?') => self.show_help = true,
KeyCode::Char('/') => self.begin_filter(),
KeyCode::Esc => {
self.password.clear();
self.status = "Login cancelled".into();
}
KeyCode::Tab | KeyCode::Down => self.next_focus(),
KeyCode::BackTab | KeyCode::Up => self.previous_focus(),
KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(),
KeyCode::Right if self.focus == Focus::Sessions => self.next_session(),
KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(),
KeyCode::F(number) => self.confirm_power_action(number),
KeyCode::Backspace => self.backspace(),
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.insert(character)
}
_ => {}
}
}
pub(super) fn confirm_power_action(&mut self, number: u8) {
if self.preview {
self.status = "Preview mode — power actions are disabled".into();
return;
}
let key = format!("F{number}");
if let Some((index, action)) = self
.config
.power_controls
.entries
.iter()
.enumerate()
.find(|(_, action)| action.key.eq_ignore_ascii_case(&key))
{
self.power_confirmation = Some(index);
self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint);
}
}
pub(super) fn run_power_action(&mut self, index: usize) {
if self.preview {
self.power_confirmation = None;
self.status = "Preview mode — power actions are disabled".into();
return;
}
let Some(action) = self.config.power_controls.entries.get(index).cloned() else {
self.power_confirmation = None;
self.status = "Power action is no longer available".into();
return;
};
if !action.program.is_absolute() {
self.power_confirmation = None;
self.status = "Power action must use an absolute program path".into();
return;
}
match std::process::Command::new(&action.program)
.args(&action.args)
.spawn()
{
Ok(_) => {
self.power_confirmation = None;
self.status = format!("Started {}", action.hint);
}
Err(error) => {
self.power_confirmation = None;
self.status = format!("Could not start {}: {error}", action.hint);
}
}
}
pub(super) fn insert(&mut self, character: char) {
if self.focus == Focus::Username {
self.account_index = None;
}
let target = match self.focus {
Focus::Username => &mut self.username,
Focus::Password => &mut self.password,
Focus::Sessions => return,
};
if target.chars().count() < MAX_INPUT {
target.push(character);
}
}
pub(super) fn backspace(&mut self) {
if self.focus == Focus::Username {
self.account_index = None;
}
let target = match self.focus {
Focus::Username => &mut self.username,
Focus::Password => &mut self.password,
Focus::Sessions => return,
};
target.pop();
}
pub(super) fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Username,
Focus::Username => Focus::Password,
Focus::Password => Focus::Sessions,
};
self.update_hint();
}
pub(super) fn previous_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Password,
Focus::Username => Focus::Sessions,
Focus::Password => Focus::Username,
};
self.update_hint();
}
pub(super) fn update_hint(&mut self) {
self.status = match self.focus {
Focus::Sessions => "Choose a session with ←/→".into(),
Focus::Username => "Enter your username".into(),
Focus::Password if self.password_change_mode => {
"Enter a new password and press Enter".into()
}
Focus::Password => "Enter your password and press Enter".into(),
};
}
pub(super) fn next_session(&mut self) {
let indices = self.matching_session_indices();
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices[(position + 1) % indices.len()];
} else if let Some(&index) = indices.first() {
self.selected = index;
}
}
pub(super) fn previous_session(&mut self) {
let indices = self.matching_session_indices();
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices
.get(position.checked_sub(1).unwrap_or(indices.len() - 1))
.copied()
.unwrap_or(self.selected);
} else if let Some(&index) = indices.first() {
self.selected = index;
}
}
pub(super) fn next_account(&mut self) {
self.select_account(1);
}
pub(super) fn previous_account(&mut self) {
self.select_account(-1);
}
pub(super) fn select_account(&mut self, delta: isize) {
let indices = self.matching_account_indices();
if indices.is_empty() {
self.notify(
"No eligible accounts discovered; enter a username manually",
NoticeLevel::Info,
);
return;
}
let current = self
.account_index
.and_then(|index| indices.iter().position(|&candidate| candidate == index))
.unwrap_or(0) as isize;
let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize];
self.select_account_index(index);
}
pub(super) fn select_account_index(&mut self, index: usize) {
let Some(username) = self
.accounts
.get(index)
.map(|account| account.username.clone())
else {
return;
};
self.account_index = Some(index);
self.username = username;
self.password.clear();
self.pending_password.clear();
self.pending_new_password.clear();
self.password_change_mode = false;
self.auth_state = AuthState::Idle;
self.auth_failures = 0;
self.auth_blocked_until = None;
self.faillock_status = None;
self.status = format!("Selected account {}", self.username);
self.apply_preferred_session();
self.start_faillock_query();
self.notify(
&format!("Selected account {}", self.username),
NoticeLevel::Info,
);
}
}
fn matches_filter(value: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
if query.chars().any(char::is_uppercase) {
value.contains(query)
} else {
value.to_lowercase().contains(query)
}
}

View File

@@ -0,0 +1,398 @@
use std::time::Instant;
use ratatui::{
prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use latchd_core::accounts::AccountState;
use super::{App, AuthState, FilterTarget, Focus, MIN_HEIGHT, MIN_WIDTH, NoticeLevel};
impl App {
pub(super) fn draw(&mut self, frame: &mut ratatui::Frame) {
let area = frame.area();
self.background.draw(frame, area, self.ascii, self.no_color);
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(format!(
"terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}"
))
.alignment(ratatui::prelude::Alignment::Center),
area,
);
return;
}
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(8),
Constraint::Length(2),
Constraint::Length(1),
])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled("latchd", self.style_accent()),
Span::raw(" secure terminal login"),
]))
.alignment(ratatui::prelude::Alignment::Center);
frame.render_widget(title, vertical[0]);
let body = if area.width < 80 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Length(8)])
.split(vertical[1])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(vertical[1])
};
let session_area = body[0];
let form_area = if body.len() == 2 {
body[1]
} else {
vertical[1]
};
let session_indices = self.matching_session_indices();
let session_items: Vec<ListItem> = session_indices
.iter()
.filter_map(|&index| self.sessions.get(index).map(|session| (index, session)))
.map(|(index, session)| {
let marker = if index == self.selected {
if self.ascii { ">" } else { "▶" }
} else {
" "
};
ListItem::new(format!(
"{marker} {}",
truncate_cells(
&session.title,
self.config.environment_switcher.max_display_length
)
))
.style(if index == self.selected {
self.style_selected()
} else {
self.style_muted()
})
})
.collect();
let session_items = if session_items.is_empty() {
vec![ListItem::new(" No matching sessions").style(self.style_muted())]
} else {
session_items
};
let session_title = if self
.filter
.as_ref()
.is_some_and(|filter| filter.target == FilterTarget::Sessions)
{
format!("Session {}/{}", session_indices.len(), self.sessions.len())
} else {
"Session".into()
};
let sessions = List::new(session_items)
.block(self.block(&session_title, self.focus == Focus::Sessions));
frame.render_widget(sessions, session_area);
let fields = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(1),
])
.split(form_area);
let account_title = self.account_title();
let username = Paragraph::new(self.username.as_str())
.block(self.block(&account_title, self.focus == Focus::Username));
let password_marker = if self.ascii { "*" } else { "•" };
let password = Paragraph::new(password_marker.repeat(self.password.chars().count())).block(
self.block(
if self.password_change_mode {
"New password"
} else {
"Password"
},
self.focus == Focus::Password,
),
);
frame.render_widget(username, fields[0]);
frame.render_widget(password, fields[1]);
let status = self.status_line();
frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]);
frame.render_widget(Paragraph::new(self.footer_hint()), vertical[2]);
frame.render_widget(
Paragraph::new("PAM authentication • session output stays off the login screen"),
vertical[3],
);
if self.show_help {
self.draw_help(frame, area);
} else if self.filter.is_some() {
self.draw_filter(frame, area);
}
}
pub(super) fn footer_hint(&self) -> String {
let mut hint = match self.focus {
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help",
Focus::Password if self.password_change_mode => {
"Tab focus Enter change password Ctrl-M motion ? help"
}
Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
}
.to_owned();
if self.no_color {
hint.push_str(" [monochrome]");
} else if self.high_contrast {
hint.push_str(" [high contrast]");
}
if self.reduced_motion {
hint.push_str(" [reduced motion]");
}
hint
}
pub(super) fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) {
let Some(filter) = &self.filter else {
return;
};
let popup = centered_rect(72, 52, area);
frame.render_widget(Clear, popup);
let (label, values): (&str, Vec<String>) = match filter.target {
FilterTarget::Sessions => (
"Sessions",
self.matching_session_indices()
.iter()
.filter_map(|&index| self.sessions.get(index))
.map(|session| session.title.clone())
.collect(),
),
FilterTarget::Accounts => (
"Accounts",
self.matching_account_indices()
.iter()
.filter_map(|&index| self.accounts.get(index))
.map(|account| account.username.clone())
.collect(),
),
};
let mut lines = vec![
Line::from(format!("{label} /{}", filter.query)),
Line::raw(""),
];
if values.is_empty() {
lines.push(Line::from("No matches"));
} else {
for value in values.iter().take(8) {
lines.push(Line::from(format!(
" {}",
truncate_cells(value, popup.width.saturating_sub(5) as usize)
)));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(
"Type to filter • ↑/↓ choose • Enter accept • Esc clear",
));
frame.render_widget(
Paragraph::new(lines)
.block(self.block("Filter", true))
.wrap(Wrap { trim: true }),
popup,
);
}
pub(super) fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
let popup = centered_rect(70, 50, area);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(
"Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / change password\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help",
)
.block(self.block("Help", false))
.wrap(Wrap { trim: true }),
popup,
);
}
pub(super) fn block(&self, title: &str, focused: bool) -> Block<'static> {
let mut block = Block::default()
.borders(Borders::ALL)
.title(title.to_owned())
.border_style(if focused {
self.style_accent()
} else {
self.style_muted()
});
if !self.no_color {
block = block.style(Style::default().bg(ratatui::prelude::Color::Black));
}
block
}
pub(super) fn account_title(&self) -> String {
self.account_index
.map(|index| format!("Username {}/{}", index + 1, self.accounts.len()))
.unwrap_or_else(|| "Username".into())
}
pub(super) fn status_line(&self) -> String {
let mut status = self.status.clone();
if matches!(
self.auth_state,
AuthState::Pending(_) | AuthState::PasswordChangePending(_)
) {
let spinner = if self.config.visual.animations && !self.reduced_motion {
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
.get(self.animation_frame % 10)
.copied()
.unwrap_or("⠋")
} else if self.ascii {
"|"
} else {
"…"
};
status = if matches!(self.auth_state, AuthState::PasswordChangePending(_)) {
format!("{spinner} Changing password through PAM…")
} else {
format!("{spinner} Authenticating…")
};
}
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
status.push_str(&format!(
" • retry cooldown {}s",
remaining.as_secs().saturating_add(1)
));
}
}
if let Some(lockout) = self.faillock_status {
if lockout.locked_out {
status
.push_str(" • advisory faillock threshold reached; PAM remains authoritative");
} else if lockout.failures > 0 {
let remaining = lockout
.remaining
.map_or_else(|| "unknown".into(), |value| value.to_string());
status.push_str(&format!(
" • {} failed attempt(s), {remaining} remaining",
lockout.failures
));
}
}
if let Some(index) = self.account_index {
if let Some(account) = self.account_statuses.get(index) {
let policy = match account.state {
AccountState::Active | AccountState::Unknown => None,
AccountState::Locked => Some("account password is locked"),
AccountState::PasswordExpired => Some("password must be changed"),
AccountState::AccountExpired => Some("account has expired"),
AccountState::AccountInactive => Some("account is inactive"),
AccountState::NoLoginShell => Some("account has no login shell"),
};
if let Some(policy) = policy {
status.push_str(&format!(" • {policy}; PAM remains authoritative"));
}
}
}
if let Some(notice) = &self.notice {
let marker = match notice.level {
NoticeLevel::Info => {
if self.ascii {
"[i]"
} else {
"ℹ"
}
}
NoticeLevel::Warning => {
if self.ascii {
"[!]"
} else {
"⚠"
}
}
};
status.push_str(&format!(" {marker} {}", notice.message));
}
if self.refreshing {
status.push_str(" • refreshing discovery");
}
status
}
pub(super) fn style_accent(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::White)
.bg(ratatui::prelude::Color::Blue)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Cyan)
.add_modifier(ratatui::prelude::Modifier::BOLD)
}
}
pub(super) fn style_selected(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::Black)
.bg(ratatui::prelude::Color::White)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Yellow)
.add_modifier(ratatui::prelude::Modifier::BOLD)
}
}
pub(super) fn style_muted(&self) -> Style {
Style::default().add_modifier(ratatui::prelude::Modifier::DIM)
}
}
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
pub(super) fn truncate_cells(value: &str, width: usize) -> String {
if UnicodeWidthStr::width(value) <= width {
return value.into();
}
let mut output = String::new();
let mut used = 0;
for character in value.chars() {
let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1);
if used + character_width + 1 > width {
break;
}
output.push(character);
used += character_width;
}
output.push('…');
output
}

View File

@@ -0,0 +1,345 @@
use std::sync::mpsc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{Terminal, backend::TestBackend};
use zeroize::Zeroizing;
use super::{auth::auth_backoff, render::truncate_cells, *};
#[test]
fn truncation_respects_terminal_cells() {
assert_eq!(truncate_cells("界界界", 5), "界界…");
}
#[test]
fn renders_the_login_screen_at_the_floor() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let buffer = terminal.backend().buffer();
let rendered = buffer
.content()
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("latchd"));
assert!(rendered.contains("Username"));
assert!(rendered.contains("Password"));
}
#[test]
fn renders_without_layout_errors_at_supported_breakpoints() {
for (width, height) in [(80, 24), (79, 23), (60, 24)] {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let rendered = terminal.backend().buffer().content();
assert!(rendered.iter().any(|cell| cell.symbol() == "l"));
assert!(
!rendered
.iter()
.any(|cell| cell.symbol() == "terminal too small")
);
}
}
#[test]
fn renders_a_truthful_too_small_state() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(59, 15);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let buffer = terminal.backend().buffer();
let rendered = buffer
.content()
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("terminal too small"));
}
#[test]
fn switching_accounts_clears_password_and_auth_state() {
let auth = AuthService::new().expect("PAM worker should start");
let accounts = vec![
Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
},
Identity {
username: "bob".into(),
uid: 1001,
gid: 1001,
home: "/home/bob".into(),
shell: "/bin/sh".into(),
},
];
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
accounts,
Background::load(&Config::default().visual).expect("default background should load"),
);
app.password = Zeroizing::new("secret".into());
app.next_account();
assert_eq!(app.username, "bob");
assert!(app.password.is_empty());
assert!(matches!(app.auth_state, AuthState::Idle));
}
#[test]
fn account_policy_state_is_visible_without_overriding_pam() {
let auth = AuthService::new().expect("PAM worker should start");
let account = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
vec![account],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.account_statuses[0].state = AccountState::Locked;
let status = app.status_line();
assert!(status.contains("password is locked"));
assert!(status.contains("PAM remains authoritative"));
}
#[test]
fn authentication_backoff_is_exponential_and_bounded() {
assert_eq!(auth_backoff(1), Duration::from_secs(1));
assert_eq!(auth_backoff(2), Duration::from_secs(2));
assert_eq!(auth_backoff(5), Duration::from_secs(16));
assert_eq!(auth_backoff(100), Duration::from_secs(16));
}
#[test]
fn pam_password_change_requirement_enters_explicit_new_password_mode() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.pending_password = Zeroizing::new(b"old-secret".to_vec());
app.handle_auth_completion(AuthCompletion::Login(Err(Error::Pam {
operation: "pam_acct_mgmt",
status: 12,
message: "module detail must stay out of the UI".into(),
})))
.expect("policy transition should not fail");
assert!(app.password_change_mode);
assert_eq!(app.focus, Focus::Password);
assert!(app.status.contains("new password"));
}
#[test]
fn password_change_mode_has_a_distinct_prompt() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.password_change_mode = true;
app.focus = Focus::Password;
app.update_hint();
assert!(app.status.contains("new password"));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal");
terminal.draw(|frame| app.draw(frame)).expect("frame");
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("New password"));
}
#[test]
fn last_session_preference_follows_account_and_persists_success() {
let auth = AuthService::new().expect("PAM worker should start");
let sessions = vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
];
let mut app = App::new(
Config::default(),
auth,
true,
sessions,
vec![Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
}],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.preferences.remember("alice", &app.sessions[1]);
app.apply_preferred_session();
assert_eq!(app.selected, 1);
let directory =
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
app.preference_path = Some(directory.join("last-session.toml"));
let selected = app.sessions[1].clone();
app.remember_selected_session(&selected);
let loaded = preferences::load(&directory.join("last-session.toml"))
.expect("selected session should persist");
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway session".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.focus = Focus::Sessions;
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert_eq!(app.matching_session_indices(), vec![1]);
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.selected, 1);
assert!(app.filter.is_none());
}
#[test]
fn refresh_reconciles_selection_without_blocking_state() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.refresh_receiver = Some({
let (sender, receiver) = mpsc::sync_channel(1);
sender
.send((
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Vec::new(),
))
.expect("refresh result should queue");
receiver
});
app.refreshing = true;
app.poll_refresh();
assert!(!app.refreshing);
assert_eq!(app.sessions.len(), 1);
}

View File

@@ -1,4 +1,4 @@
//! Low-cost terminal wallpaper and procedural ambient animation.
//! Draw terminal wallpaper and a small ambient animation.
use std::{
fs,
@@ -189,7 +189,7 @@ struct Automata {
impl Automata {
fn resize(&mut self, width: usize, height: usize) {
// Cap the state size. Some terminal emulators report extreme dimensions.
// Keep the state bounded when a terminal reports an extreme size.
let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height {

File diff suppressed because it is too large Load Diff