Add selectable Pixel clock styling and widget font controls

This commit is contained in:
Samuel Onoja committed 2026-09-27 06:55:21 +01:00
1 parent d71fab7102
commit dcca5fec88
14 files changed
+479 -37

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
use std::{process::Command, sync::OnceLock};
pub fn families() -> &'static [String] {
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
FAMILIES.get_or_init(|| {
let output = Command::new("fc-list")
.args(["--format", "%{family}\\n"])
.output()
.ok()
.filter(|output| output.status.success());
let mut families: Vec<String> = output
.as_ref()
.map(|output| String::from_utf8_lossy(&output.stdout))
.unwrap_or_default()
.lines()
.flat_map(|line| line.split(','))
.map(str::trim)
.filter(|family| !family.is_empty() && family.len() <= 128)
.map(str::to_owned)
.collect();
families.extend([
"Comfortaa".into(),
"sans-serif".into(),
"serif".into(),
"monospace".into(),
]);
families.sort_unstable_by_key(|family| family.to_lowercase());
families.dedup();
families.insert(0, "Default font".into());
families
})
}
+42
View File
@@ -1,3 +1,4 @@
mod fonts;
mod schema;
mod store;
mod visuals;
@@ -26,6 +27,7 @@ fn main() -> cosmic::iced::Result {
None => store::config_path(),
};
let path = path.canonicalize().unwrap_or(path);
fonts::families();
let initial = Snapshot::read(&path);
let theme = visuals::native_theme(initial.as_ref().ok());
@@ -45,6 +47,7 @@ enum Message {
Page(Page),
Search(String),
Change(Edit),
SelectFont(String, String),
Draft(String, String),
Commit(Field),
Range(Field, f64),
@@ -212,6 +215,10 @@ impl cosmic::Application for App {
self.sync_notes();
return task;
}
Message::SelectFont(path, family) => {
self.inputs.remove(&path);
return self.change(set(&path, family));
}
Message::DragWindow => return self.core.drag(None),
Message::ExternalConfig(result) => match result {
Ok(snapshot)
@@ -1085,6 +1092,41 @@ impl App {
let labels = labels.width(Length::Fill);
let path = field.path.clone();
let control: Element<'static, Message> = match field.kind.clone() {
Kind::Font => {
let value = self
.inputs
.get(&path)
.cloned()
.unwrap_or_else(|| self.draft.string(&path, ""));
let families = fonts::families();
let selected = if value.is_empty() {
Some(0)
} else {
families.iter().position(|family| family == &value)
};
let selection_path = path.clone();
let commit = field.clone();
column([])
.spacing(6)
.push(
widget::dropdown(families, selected, move |index| {
let family = if index == 0 { "" } else { &families[index] };
Message::SelectFont(selection_path.clone(), family.to_owned())
})
.width(225),
)
.push(
text_input("Default font", value)
.font(self.font)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone()))
.on_unfocus(Message::Commit(commit))
.width(225)
.size(12),
)
.into()
}
Kind::Toggle(default) => toggler(self.draft.boolean(&path, default))
.size(22)
.on_toggle(move |value| Message::Change(set(&path, value)))
+18 -10
View File
@@ -90,6 +90,7 @@ impl Page {
#[derive(Clone, Debug)]
pub enum Kind {
Font,
Toggle(bool),
Range {
default: f64,
@@ -198,6 +199,13 @@ fn clock_fields() -> Vec<Field> {
"Behind windows and click-through.",
false,
),
choice(
"desktop_widgets.clock.style",
"Clock style",
"Pixel stacks heavy, alternating-color digits; Minimal uses plain text.",
"pixel",
&[("pixel", "Pixel"), ("minimal", "Minimal")],
),
toggle(
"desktop_widgets.clock.bold",
"Bold text",
@@ -207,7 +215,7 @@ fn clock_fields() -> Vec<Field> {
toggle(
"desktop_widgets.clock.show_date",
"Show date",
"A separate line beneath the time.",
"A small date above the digits in Pixel; beneath the time in Minimal.",
true,
),
toggle(
@@ -222,11 +230,11 @@ fn clock_fields() -> Vec<Field> {
"top_left, top_center, top_right, center_left, center, center_right, bottom_left, bottom_center, bottom_right",
"top_left",
),
text(
Field::new(
"desktop_widgets.clock.font_family",
"Font",
"Empty follows the desktop font.",
"",
"Choose an installed family or enter a name. Empty uses the style default.",
Kind::Font,
),
text(
"desktop_widgets.clock.time_format",
@@ -238,7 +246,7 @@ fn clock_fields() -> Vec<Field> {
"desktop_widgets.clock.date_format",
"Date format",
"%A, %-d %B for weekday and date; %-d %b for a short date.",
"%A, %-d %B",
"%a, %b %-d",
),
text(
"desktop_widgets.clock.time_zone",
@@ -301,7 +309,7 @@ fn clock_fields() -> Vec<Field> {
"desktop_widgets.clock.height",
"Widget height",
"Leave room for both text lines.",
160.0,
320.0,
32.0,
800.0,
1.0,
@@ -312,7 +320,7 @@ fn clock_fields() -> Vec<Field> {
"desktop_widgets.clock.time_size",
"Time size",
"Logical pixels; scales with your output.",
72.0,
128.0,
8.0,
240.0,
1.0,
@@ -411,11 +419,11 @@ pub fn note_fields(index: usize) -> Vec<Field> {
"Nine positions, e.g. top_right or center.",
"top_right",
),
text(
Field::new(
format!("{prefix}.font_family"),
"Font",
"Empty follows the desktop font.",
"",
"Choose an installed family or enter a name. Empty follows the desktop font.",
Kind::Font,
),
text(
format!("{prefix}.color"),
+1
View File
@@ -384,6 +384,7 @@ window-rule app-id="mine" floating=#true
for field in fields {
use crate::schema::Kind;
let values: Vec<Value> = match field.kind {
Kind::Font => vec!["".into(), "sans-serif".into()],
Kind::Toggle(_) => vec![true.into(), false.into()],
Kind::Range {
min, max, integer, ..
+151 -17
View File
@@ -107,6 +107,30 @@ impl From<ShellTheme> for BarMetrics {
}
}
fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
let mut parts = label.split_whitespace();
let time = parts.next()?;
if let Some(period) = parts.next() {
if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") {
return None;
}
}
if parts.next().is_some() {
return None;
}
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
|| !hour
.bytes()
.chain(minute.bytes())
.all(|digit| digit.is_ascii_digit())
{
return None;
}
Some([format!("{hour:0>2}"), minute.to_owned()])
}
fn main() -> cosmic::iced::Result {
// Small shell surfaces do not need a second GPU device and shader setup.
// Keep wgpu as a fallback and honor an explicit renderer preference.
@@ -115,6 +139,18 @@ fn main() -> cosmic::iced::Result {
// toolkit threads are started.
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"../../../assets/fonts/Comfortaa-Regular.otf"
)));
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"../../../assets/fonts/Cantarell-ExtraBold.otf"
)));
let mut config = config::load();
motion::configure(config.animations);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
@@ -1336,16 +1372,22 @@ impl FereseShell {
ClockAlignment::Center => alignment::Horizontal::Center,
ClockAlignment::Right => alignment::Horizontal::Right,
};
let mut font = configured_font(
clock
.font_family
.as_deref()
.filter(|family| !family.trim().is_empty())
.or(self.config.font_family.as_deref()),
);
if clock.bold {
let pixel = clock.style == ferese_core::desktop::ClockStyle::Pixel;
let custom_font = clock
.font_family
.as_deref()
.filter(|family| !family.trim().is_empty());
let mut font = configured_font(if pixel {
custom_font.or(Some("Cantarell"))
} else {
custom_font.or(self.config.font_family.as_deref())
});
if pixel && custom_font.is_none() {
font.weight = cosmic::iced::font::Weight::Black;
} else if clock.bold {
font.weight = cosmic::iced::font::Weight::Bold;
}
let date_font = configured_font(custom_font.or(self.config.font_family.as_deref()));
let tint = |custom: &Option<String>, fallback| {
let mut tint = color(
custom
@@ -1356,21 +1398,98 @@ impl FereseShell {
tint.a *= clock.opacity;
tint
};
let foreground = tint(&clock.color, self.config.theme.text_primary);
let accent = color(self.config.theme.accent);
let custom_color = clock
.color
.as_deref()
.and_then(config::parse_color)
.is_some();
let digit_colors = [0.8, 0.45].map(|text_mix| {
if custom_color {
return foreground;
}
Color {
r: foreground.r * text_mix + accent.r * (1.0 - text_mix),
g: foreground.g * text_mix + accent.g * (1.0 - text_mix),
b: foreground.b * text_mix + accent.b * (1.0 - text_mix),
a: foreground.a,
}
});
let mut labels = cosmic::widget::column([])
.spacing(clock.gap)
.align_x(alignment)
.push(
cosmic::widget::text(self.desktop_clock.0.clone())
.font(font)
.size(clock.time_size)
.width(Length::Fill)
.align_x(alignment)
.align_x(alignment);
if pixel && clock.show_date {
labels = labels.push(
cosmic::widget::text(self.desktop_clock.1.clone())
.font(date_font)
.size(clock.date_size)
.class(theme::Text::Color(tint(
&clock.color,
&clock.date_color,
self.config.theme.text_primary,
))),
);
if clock.show_date {
}
if let Some(digits) = pixel
.then(|| stacked_clock_digits(&self.desktop_clock.0))
.flatten()
{
let available_height = (clock.height as f32
- clock.padding * 2.0
- if clock.show_date {
clock.date_size * 1.3 + clock.gap
} else {
0.0
})
.max(16.0);
let size = clock
.time_size
.min(available_height / 1.6)
.min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5);
let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment);
for (line, digits) in digits.into_iter().enumerate() {
let mut digit_row = row([]).spacing(0);
for (position, digit) in digits.chars().enumerate() {
digit_row = digit_row.push(
cosmic::widget::text(digit.to_string())
.font(font)
.size(size)
.line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8))
.class(theme::Text::Color(digit_colors[(line + position) % 2])),
);
}
stack = stack.push(digit_row);
}
labels = labels.push(stack);
} else {
let characters = self.desktop_clock.0.chars().count().max(1) as f32;
let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0);
let available_height = (clock.height as f32
- clock.padding * 2.0
- if clock.show_date {
clock.date_size * 1.3 + clock.gap
} else {
0.0
})
.max(1.0);
let size = clock
.time_size
.min(available_width / (characters * 0.75))
.min(available_height / 1.3);
labels = labels.push(
cosmic::widget::text(self.desktop_clock.0.clone())
.font(font)
.size(size)
.width(Length::Fill)
.align_x(alignment)
.class(theme::Text::Color(foreground)),
);
}
if !pixel && clock.show_date {
labels = labels.push(
cosmic::widget::text(self.desktop_clock.1.clone())
.font(font)
@@ -2381,6 +2500,21 @@ mod tests {
);
}
#[test]
fn pixel_clock_stacks_only_hour_and_minute_formats() {
assert_eq!(
super::stacked_clock_digits("2:45 pm"),
Some(["02".into(), "45".into()])
);
assert_eq!(
super::stacked_clock_digits("14:45"),
Some(["14".into(), "45".into()])
);
assert!(super::stacked_clock_digits("14:45:30").is_none());
assert!(super::stacked_clock_digits("Today 14:45").is_none());
assert!(super::stacked_clock_digits("14:45 pm extra").is_none());
}
#[test]
fn note_drag_origin_and_bounds_use_logical_output_coordinates() {
use cosmic::iced::Point;