use std::error::Error;
use std::fmt;
use std::time::SystemTime;
use chrono::{DateTime, Local, Timelike};
use rune_cfg::RuneConfig;
const NORMAL_MARGIN_PX: f32 = 18.0;
const COLLAPSED_EDGE_PADDING_PX: f32 = 2.0;
const MIN_COLLAPSED_FONT_PX: u32 = 12;
const COLLAPSED_FONT_SCALE: f32 = 0.56;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ApertureMode {
#[default]
Normal,
Collapsed,
Hidden,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum AperturePlacement {
#[default]
Cursor,
All,
Monitor,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum PeekCorner {
TopLeft,
#[default]
TopRight,
BottomLeft,
BottomRight,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ClockColor {
pub(crate) r: f32,
pub(crate) g: f32,
pub(crate) b: f32,
}
impl Default for ClockColor {
fn default() -> Self {
Self {
r: 0.96,
g: 0.98,
b: 1.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct PeekBackgroundColor {
pub(crate) r: f32,
pub(crate) g: f32,
pub(crate) b: f32,
pub(crate) a: f32,
}
impl Default for PeekBackgroundColor {
fn default() -> Self {
Self {
r: 16.0 / 255.0,
g: 16.0 / 255.0,
b: 20.0 / 255.0,
a: 204.0 / 255.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ClockConfig {
pub(crate) font_family: String,
pub(crate) font_px: u32,
pub(crate) color: ClockColor,
}
impl Default for ClockConfig {
fn default() -> Self {
Self {
font_family: "monospace".to_string(),
font_px: 30,
color: ClockColor::default(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct AperturePeekConfig {
pub(crate) corner: PeekCorner,
pub(crate) background: PeekBackgroundColor,
pub(crate) radius_px: u32,
pub(crate) clock: ClockConfig,
}
impl Default for AperturePeekConfig {
fn default() -> Self {
Self {
corner: PeekCorner::TopRight,
background: PeekBackgroundColor::default(),
radius_px: 24,
clock: ClockConfig::default(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ApertureConfig {
pub(crate) placement: AperturePlacement,
pub(crate) monitor: Option<String>,
pub(crate) peek: AperturePeekConfig,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ApertureConfigError {
message: String,
}
impl ApertureConfigError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ApertureConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message.as_str())
}
}
impl Error for ApertureConfigError {}
impl ApertureConfig {
pub(crate) fn parse_str(raw: &str) -> Result<Self, ApertureConfigError> {
if raw.trim().is_empty() {
return Ok(Self::default());
}
let cfg = RuneConfig::from_str(raw).map_err(|err| {
ApertureConfigError::new(format!("aperture config parse error: {err}"))
})?;
let mut out = Self::default();
out.placement = pick_string(&cfg, &["aperture.placement"])
.as_deref()
.and_then(parse_placement)
.unwrap_or(out.placement);
out.monitor = pick_string(&cfg, &["aperture.monitor"]).and_then(non_empty_trimmed);
out.peek.corner = pick_string(&cfg, &["aperture-peek.corner"])
.as_deref()
.and_then(parse_corner)
.unwrap_or(out.peek.corner);
out.peek.background = pick_background_color(
&cfg,
&[
"aperture-peek.background",
"aperture-peek.background-colour",
"aperture-peek.background-color",
],
out.peek.background,
);
out.peek.radius_px = pick_u32(
&cfg,
&[
"aperture-peek.radius-px",
"aperture-peek.radius_px",
"aperture-peek.corner-radius-px",
"aperture-peek.corner_radius_px",
],
out.peek.radius_px,
);
out.peek.clock.font_family = pick_string(
&cfg,
&[
"aperture-peek.clock.font",
"aperture-peek.clock.font-family",
"aperture-peek.clock.font_family",
"aperture-peek.clock.family",
],
)
.unwrap_or(out.peek.clock.font_family)
.trim()
.to_string();
if out.peek.clock.font_family.is_empty() {
out.peek.clock.font_family = ClockConfig::default().font_family;
}
out.peek.clock.font_px = pick_u32(
&cfg,
&[
"aperture-peek.clock.size-px",
"aperture-peek.clock.size_px",
"aperture-peek.clock.font-px",
"aperture-peek.clock.font_px",
],
out.peek.clock.font_px,
)
.max(1);
out.peek.clock.color = pick_clock_color(
&cfg,
&["aperture-peek.clock.colour", "aperture-peek.clock.color"],
out.peek.clock.color,
);
Ok(out)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Point {
pub(crate) x: f32,
pub(crate) y: f32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Size {
pub(crate) w: f32,
pub(crate) h: f32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Rect {
pub(crate) x: f32,
pub(crate) y: f32,
pub(crate) w: f32,
pub(crate) h: f32,
}
impl Rect {
pub(crate) const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Self { x, y, w, h }
}
pub(crate) fn right(self) -> f32 {
self.x + self.w
}
pub(crate) fn bottom(self) -> f32 {
self.y + self.h
}
pub(crate) fn is_empty(self) -> bool {
self.w <= 0.0 || self.h <= 0.0
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ClockSnapshot {
pub(crate) text: String,
pub(crate) font_family: String,
pub(crate) font_px: u32,
pub(crate) alpha: f32,
pub(crate) bounds: Rect,
pub(crate) text_origin: Point,
}
#[derive(Clone, Debug)]
pub(crate) struct ApertureRuntime {
config: ApertureConfig,
clock_text: String,
}
impl ApertureRuntime {
pub(crate) fn new(config: ApertureConfig) -> Self {
let mut out = Self {
config,
clock_text: String::new(),
};
out.refresh_clock_text(SystemTime::now());
out
}
pub(crate) fn config(&self) -> &ApertureConfig {
&self.config
}
pub(crate) fn apply_config(&mut self, config: ApertureConfig) {
self.config = config;
}
pub(crate) fn snapshot_for_mode<F>(
&self,
mode: ApertureMode,
output_rect: Rect,
work_area_rect: Rect,
scale: f64,
mut measure_text: F,
) -> Option<ClockSnapshot>
where
F: FnMut(u32, &str) -> Size,
{
let text = self.clock_text.clone();
if text.is_empty() {
return None;
}
let effective_scale = scale.max(0.25) as f32;
let render_font_px = match mode {
ApertureMode::Normal => self.config.peek.clock.font_px.max(1),
ApertureMode::Collapsed | ApertureMode::Hidden => {
collapsed_font_px(self.config.peek.clock.font_px)
}
};
let render_font_px = (render_font_px as f32 * effective_scale).round().max(1.0) as u32;
let text_size = measure_text(render_font_px, text.as_str());
if text_size.w <= 0.0 || text_size.h <= 0.0 {
return None;
}
let work_rect = if work_area_rect.is_empty() {
output_rect
} else {
work_area_rect
};
let side_margin = NORMAL_MARGIN_PX * effective_scale;
let edge_padding = match mode {
ApertureMode::Normal => NORMAL_MARGIN_PX,
ApertureMode::Collapsed | ApertureMode::Hidden => COLLAPSED_EDGE_PADDING_PX,
} * effective_scale;
let x = work_rect.right() - side_margin - text_size.w;
let y = work_rect.y + edge_padding;
if mode == ApertureMode::Hidden {
return None;
}
Some(ClockSnapshot {
text,
font_family: self.config.peek.clock.font_family.clone(),
font_px: render_font_px,
alpha: 1.0,
bounds: Rect::new(x, y, text_size.w, text_size.h),
text_origin: Point { x, y },
})
}
fn refresh_clock_text(&mut self, now: SystemTime) {
let local: DateTime<Local> = now.into();
self.clock_text = format!("{:02}:{:02}", local.hour(), local.minute());
}
}
fn collapsed_font_px(normal_font_px: u32) -> u32 {
((normal_font_px.max(1) as f32) * COLLAPSED_FONT_SCALE)
.round()
.max(MIN_COLLAPSED_FONT_PX as f32) as u32
}
fn pick_u32(cfg: &RuneConfig, paths: &[&str], default: u32) -> u32 {
for path in paths {
if let Ok(Some(value)) = cfg.get_optional::<u32>(path) {
return value;
}
}
default
}
fn pick_string(cfg: &RuneConfig, paths: &[&str]) -> Option<String> {
for path in paths {
if let Ok(Some(value)) = cfg.get_optional::<String>(path) {
return Some(value);
}
}
None
}
fn non_empty_trimmed(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn parse_placement(value: &str) -> Option<AperturePlacement> {
match value.trim().to_ascii_lowercase().as_str() {
"cursor" => Some(AperturePlacement::Cursor),
"all" => Some(AperturePlacement::All),
"monitor" | "output" => Some(AperturePlacement::Monitor),
_ => None,
}
}
fn parse_corner(value: &str) -> Option<PeekCorner> {
match value.trim().to_ascii_lowercase().as_str() {
"top-left" | "top_left" => Some(PeekCorner::TopLeft),
"top-right" | "top_right" => Some(PeekCorner::TopRight),
"bottom-left" | "bottom_left" => Some(PeekCorner::BottomLeft),
"bottom-right" | "bottom_right" => Some(PeekCorner::BottomRight),
_ => None,
}
}
fn pick_clock_color(cfg: &RuneConfig, paths: &[&str], default: ClockColor) -> ClockColor {
let Some(raw) = pick_string(cfg, paths) else {
return default;
};
parse_hex_rgb(raw.trim().trim_matches('"')).unwrap_or(default)
}
fn pick_background_color(
cfg: &RuneConfig,
paths: &[&str],
default: PeekBackgroundColor,
) -> PeekBackgroundColor {
let Some(raw) = pick_string(cfg, paths) else {
return default;
};
parse_hex_rgba(raw.trim().trim_matches('"')).unwrap_or(default)
}
fn parse_hex_rgb(value: &str) -> Option<ClockColor> {
let rgba = parse_hex_rgba(value)?;
Some(ClockColor {
r: rgba.r,
g: rgba.g,
b: rgba.b,
})
}
fn parse_hex_rgba(value: &str) -> Option<PeekBackgroundColor> {
let hex = value.strip_prefix('#').unwrap_or(value);
let expanded = match hex.len() {
3 => {
let mut out = String::with_capacity(8);
for ch in hex.chars() {
out.push(ch);
out.push(ch);
}
out.push_str("ff");
out
}
4 => {
let mut out = String::with_capacity(8);
for ch in hex.chars() {
out.push(ch);
out.push(ch);
}
out
}
6 => {
let mut out = hex.to_string();
out.push_str("ff");
out
}
8 => hex.to_string(),
_ => return None,
};
let r = u8::from_str_radix(&expanded[0..2], 16).ok()? as f32 / 255.0;
let g = u8::from_str_radix(&expanded[2..4], 16).ok()? as f32 / 255.0;
let b = u8::from_str_radix(&expanded[4..6], 16).ok()? as f32 / 255.0;
let a = u8::from_str_radix(&expanded[6..8], 16).ok()? as f32 / 255.0;
Some(PeekBackgroundColor { r, g, b, a })
}