diff options
| author | srdusr <[email protected]> | 2026-07-07 23:32:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-07-07 23:32:00 +0200 |
| commit | a6f98eba60f71f7250f4383e76642a901c5b8439 (patch) | |
| tree | cf85acae377514cc8e14751347b4b19655884a1a /crates/wayland | |
| parent | ad5a7349d28ad52032fff6885e2d8b80ea28dbce (diff) | |
| download | srdwm-a6f98eba60f71f7250f4383e76642a901c5b8439.tar.gz srdwm-a6f98eba60f71f7250f4383e76642a901c5b8439.zip | |
Lock screen: show each key's alternate character, use a UI font, space the dots
Three reports, three separate causes.
Alternate characters were invisible. A keycap drew only the character the
current shift state types, so there was no way to find a symbol without
pressing Shift and hunting for it - a physical key is labelled with both.
Each cap now draws the other state's character small and dimmed in its
top-right, skipped where the two are the same (every letter differs only by
case, which the cap already shows) and for named keys like Enter.
"Enter Password" rendered in a monospace font. find_system_font
deliberately prefers a mono face, which is right for a titlebar title or a
menu row sitting in columns and wrong for a sentence; on this machine it
resolves to DejaVu Sans Mono. New find_ui_font prefers a proportional face
from a ranked list of widely-installed families, falling back to the mono
one, and the lock screen's prose - prompt, clock, date, username, status --
uses it. Ranked rather than first-found so the result does not depend on
directory order, which is how the mono scan once picked an italic face and
rendered every titlebar in italic.
The password dots had no spacing. They were drawn as a plain string, so a
run of identical bullets separated only by their own advance read as one
smeared blob rather than countable characters. Added explicit tracking,
excluded from the centring width so the row does not sit half a gap left.
531 tests pass, clippy clean.
Diffstat (limited to 'crates/wayland')
| -rw-r--r-- | crates/wayland/src/decoration.rs | 2 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/font.rs | 67 | ||||
| -rw-r--r-- | crates/wayland/src/native_lock.rs | 66 |
3 files changed, 123 insertions, 12 deletions
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs index 70b39c4..21002f7 100644 --- a/crates/wayland/src/decoration.rs +++ b/crates/wayland/src/decoration.rs @@ -42,7 +42,7 @@ pub(crate) use border::{border_bottom_visible_rows, border_top_visible_rows}; pub(crate) use buttons::HOVER_GLYPH_DURATION; pub(crate) use color::{mix_rgb, rgb_to_bgra}; pub(crate) use corners::{round_bottom_corners, round_top_corners}; -pub(crate) use font::{blit_glyph, blit_glyph_over, find_system_font, measure_text_width, FONT_PIXELS, TEXT_LEFT_PADDING}; +pub(crate) use font::{blit_glyph, blit_glyph_over, find_system_font, find_ui_font, measure_text_width, FONT_PIXELS, TEXT_LEFT_PADDING}; pub use shadow::{shadow_bitmap, shadow_rect, shadow_rect_clipped}; pub(crate) use shadow::{SHADOW_MAX_ALPHA, SHADOW_SIZE}; pub use titlebar::render_titlebar; diff --git a/crates/wayland/src/decoration/font.rs b/crates/wayland/src/decoration/font.rs index 74d5047..b3ad782 100644 --- a/crates/wayland/src/decoration/font.rs +++ b/crates/wayland/src/decoration/font.rs @@ -31,6 +31,73 @@ pub(crate) fn measure_text_width(font: &Option<Font>, text: &str, size: f32) -> text.chars().filter(|c| !c.is_control()).map(|ch| font.rasterize(ch, size).0.advance_width).sum() } +/// A proportional UI font for prose, falling back to the monospace one. +/// +/// [`find_system_font`] deliberately prefers a *monospace* face, which is +/// right for a titlebar title and a menu row - they sit in columns and +/// benefit from even advances. It is wrong for a sentence. The lock +/// screen's "Enter Password" prompt rendered in DejaVu Sans Mono, which is +/// what this machine's font scan picks, and reads as a mistake next to +/// every other prompt on the system. Reported as the prompt having "a weird +/// font". +/// +/// Ranked by preference rather than taking whatever a directory walk turns +/// up first, so the result does not depend on filesystem order the way the +/// mono scan historically did (it once picked an italic face and rendered +/// every titlebar in italic). +pub(crate) fn find_ui_font() -> Option<Font> { + static FONT: OnceLock<Option<Font>> = OnceLock::new(); + FONT.get_or_init(|| load_ui_font().or_else(load_any_monospace_font)).clone() +} + +fn load_ui_font() -> Option<Font> { + // Common, widely-installed proportional faces, best first. Each is + // matched against the whole path lowercased, so a distro's own + // subdirectory layout does not matter. + const PREFERRED: [&str; 8] = + ["inter-regular", "cantarell-regular", "notosans-regular", "dejavusans.ttf", "liberationsans-regular", "opensans-regular", "roboto-regular", "freesans"]; + let mut roots: Vec<String> = ["/usr/share/fonts", "/usr/local/share/fonts"].iter().map(|s| s.to_string()).collect(); + if let Ok(home) = std::env::var("HOME") { + roots.push(format!("{home}/.local/share/fonts")); + roots.push(format!("{home}/.fonts")); + } + let mut best: Option<(std::path::PathBuf, usize)> = None; + for root in &roots { + collect_ui_font(std::path::Path::new(root), &PREFERRED, &mut best); + } + let (path, _) = best?; + let bytes = std::fs::read(&path).ok()?; + match Font::from_bytes(bytes, FontSettings::default()) { + Ok(f) => { + log::info!("ui font: {}", path.display()); + Some(f) + } + Err(e) => { + log::warn!("failed to parse ui font {}: {e}", path.display()); + None + } + } +} + +fn collect_ui_font(dir: &std::path::Path, preferred: &[&str], best: &mut Option<(std::path::PathBuf, usize)>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_ui_font(&path, preferred, best); + continue; + } + let name = path.to_string_lossy().to_lowercase(); + if !(name.ends_with(".ttf") || name.ends_with(".otf")) || name.contains("mono") || name.contains("italic") || name.contains("oblique") { + continue; + } + let Some(rank) = preferred.iter().position(|p| name.contains(p)) else { continue }; + if best.as_ref().is_none_or(|(_, r)| rank < *r) { + *best = Some((path, rank)); + } + } +} + fn load_any_monospace_font() -> Option<Font> { let roots = ["/usr/share/fonts", "/usr/local/share/fonts"]; let mut home_roots = Vec::new(); diff --git a/crates/wayland/src/native_lock.rs b/crates/wayland/src/native_lock.rs index b0a5c44..7ec3e9e 100644 --- a/crates/wayland/src/native_lock.rs +++ b/crates/wayland/src/native_lock.rs @@ -803,7 +803,7 @@ fn fill_circle_on_transparent(buf: &mut [u8], width: usize, height: usize, cx: i /// box`, which is a genuinely opaque panel, nothing here is a filled /// background at all. fn render_header_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8>, (i32, i32)) { - use crate::decoration::{blit_glyph_on_transparent, find_system_font, FONT_PIXELS}; + use crate::decoration::{blit_glyph_on_transparent, find_ui_font, FONT_PIXELS}; const WIDTH: usize = 420; const HEIGHT: usize = 200; @@ -812,7 +812,7 @@ fn render_header_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Ve const AVATAR_RADIUS: i32 = 28; let mut buf = vec![0u8; WIDTH * HEIGHT * 4]; - let font = find_system_font(); + let font = find_ui_font(); let (hour, minute, weekday, day, month, _year) = local_time_now(); let time_str = format!("{hour:02}:{minute:02}"); let date_str = format!("{}, {} {day}", WEEKDAY_NAMES[weekday.clamp(0, 6) as usize], MONTH_NAMES[(month - 1).clamp(0, 11) as usize]); @@ -880,7 +880,7 @@ fn render_header_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Ve /// `rgb_to_bgra`), promoted to `pub(crate)` there rather than duplicated /// here. fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8>, (i32, i32)) { - use crate::decoration::{blit_glyph_over, find_system_font, rgb_to_bgra, round_bottom_corners, round_top_corners, FONT_PIXELS, TEXT_LEFT_PADDING}; + use crate::decoration::{blit_glyph_over, find_ui_font, rgb_to_bgra, round_bottom_corners, round_top_corners, FONT_PIXELS, TEXT_LEFT_PADDING}; const WIDTH: usize = 340; const HEIGHT: usize = 120; @@ -898,7 +898,7 @@ fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8 } } - let font = find_system_font(); + let font = find_ui_font(); let text_color = if native.show_error { theme.error_color } else { theme.text_color }; // Centered, not left-padded like every other text row this codebase @@ -909,9 +909,18 @@ fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8 // (the same two-pass "measure, then centre" `render_header_box`'s own // `draw_centered` already does) rather than repeating that closure // here for one extra parameter's difference. - let mut draw_line_centered = |text: &str, y: f32, color: (u8, u8, u8)| { + // `tracking` is extra space added after every glyph. Zero for prose; + // the password dots use it because a run of identical bullets with only + // their own advance between them reads as one smeared blob rather than + // as countable characters - reported as "no spacing in between the + // obfuscated password entry". Every real password field spaces them. + let mut draw_line_centered_tracked = |text: &str, y: f32, color: (u8, u8, u8), tracking: f32| { let Some(font) = &font else { return }; - let total_width: f32 = text.chars().map(|ch| font.rasterize(ch, FONT_PIXELS).0.advance_width).sum(); + let count = text.chars().filter(|c| !c.is_control()).count() as f32; + // The trailing gap is not part of the drawn run, so it must not be + // counted when centring or the row sits half a gap to the left. + let glyphs: f32 = text.chars().map(|ch| font.rasterize(ch, FONT_PIXELS).0.advance_width).sum(); + let total_width: f32 = glyphs + tracking * (count - 1.0).max(0.0); let start_x = ((WIDTH as f32 - total_width) / 2.0).max(TEXT_LEFT_PADDING); let mut pen_x = start_x; for ch in text.chars() { @@ -924,7 +933,7 @@ fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8 let glyph_y = y - metrics.height as f32 - metrics.ymin as f32; blit_glyph_over(&mut buf, WIDTH, HEIGHT, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, color); } - pen_x += metrics.advance_width; + pen_x += metrics.advance_width + tracking; } }; @@ -940,20 +949,20 @@ fn render_ui_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec<u8 // actually drawn would tint the placeholder for no visible reason. let toward = if panel_alpha > 0 { theme.box_bg } else { (0, 0, 0) }; let placeholder = crate::decoration::mix_rgb(theme.text_color, toward, 0.5); - draw_line_centered("Enter Password", 65.0, placeholder); + draw_line_centered_tracked("Enter Password", 65.0, placeholder, 0.0); } else { let dots: String = std::iter::repeat_n(theme.dot_char, native.password.chars().count()).collect(); - draw_line_centered(&dots, 65.0, text_color); + draw_line_centered_tracked(&dots, 65.0, text_color, DOT_TRACKING); } let mut status_y = 100.0; if theme.show_caps_lock && native.caps_lock { - draw_line_centered("Caps Lock is on", status_y, theme.error_color); + draw_line_centered_tracked("Caps Lock is on", status_y, theme.error_color, 0.0); status_y += 20.0; } if theme.show_failed_attempts && native.show_error { let message = if native.failed_attempts > 1 { format!("{} ({} attempts)", theme.fail_message, native.failed_attempts) } else { theme.fail_message.clone() }; - draw_line_centered(&message, status_y, theme.error_color); + draw_line_centered_tracked(&message, status_y, theme.error_color, 0.0); } // Border, drawn last so it isn't overdrawn by any fill above -- @@ -1099,6 +1108,10 @@ fn keyboard_rows() -> [Vec<KeySpec>; 5] { ] } +/// Extra space after each password dot. Without it a run of identical +/// bullets renders as one smeared blob rather than countable characters. +const DOT_TRACKING: f32 = 5.0; + const KEY_UNIT: i32 = 32; const KEY_HEIGHT: i32 = 32; const KEY_GAP: i32 = 6; @@ -1140,6 +1153,37 @@ fn render_keyboard(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Vec< let w = (spec.width * KEY_UNIT as f32).round() as i32; fill_rect(&mut buf, width, height, x, y, x + w, y + KEY_HEIGHT, keycap_bg, 220); let label = if native.shift { spec.upper } else { spec.lower }; + // The character the OTHER shift state would type, drawn small + // in the cap's top-right. A physical key is labelled with both + // (`2` and `@` on one cap) and this was labelled with only the + // active one, so there was no way to find a symbol without + // pressing Shift and hunting for it. Reported as not being able + // to see the alternative characters. + // + // Skipped when the two are the same character (every letter + // key differs only by case, which the cap already shows, and a + // named key like Enter has no alternate at all). + let alternate = if native.shift { spec.lower } else { spec.upper }; + if spec.name.is_empty() && alternate != label && !alternate.is_empty() { + if let Some(font) = &font { + let size = FONT_PIXELS * 0.62; + let alt_width: f32 = alternate.chars().map(|ch| font.rasterize(ch, size).0.advance_width).sum(); + let mut pen_x = x as f32 + w as f32 - alt_width - 3.0; + for ch in alternate.chars() { + let (metrics, coverage) = font.rasterize(ch, size); + if metrics.width > 0 && metrics.height > 0 { + let glyph_x = pen_x + metrics.xmin as f32; + let glyph_y = y as f32 + 3.0 + size - metrics.height as f32 - metrics.ymin as f32; + // Dimmed toward the cap so it reads as + // secondary rather than competing with the + // character the key actually types right now. + let dim = crate::decoration::mix_rgb(theme.text_color, keycap_bg, 0.45); + blit_glyph_on_transparent(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, dim); + } + pen_x += metrics.advance_width; + } + } + } if let Some(font) = &font { let total_width: f32 = label.chars().map(|ch| font.rasterize(ch, FONT_PIXELS).0.advance_width).sum(); let mut pen_x = x as f32 + (w as f32 - total_width) / 2.0; |