diff options
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; |