Separate library implementations from module declarations
This commit is contained in:
1 parent
1b4db0e681
commit
23e280a6b4
19 files changed
+6250
-6213
No files matched your search
@@ -1,631 +1,9 @@
|
||||
//! Typed activity data and deterministic policy. The host supplies verified owners
|
||||
//! and suspend-aware milliseconds; this crate performs no I/O or rendering.
|
||||
pub mod pomodoro;
|
||||
mod store;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type Time = u64;
|
||||
const BOOST_MS: u64 = 5_000;
|
||||
pub const MAX_TEXT: usize = 256;
|
||||
pub const MAX_LEASE_MS: u64 = 300_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Owner {
|
||||
pub pid: u32,
|
||||
pub birth: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Id {
|
||||
pub owner: Owner,
|
||||
pub local: String,
|
||||
pub generation: u64,
|
||||
pub session: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
content = "value",
|
||||
rename_all = "kebab-case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Value {
|
||||
Indeterminate,
|
||||
Progress(f32),
|
||||
Text(String),
|
||||
Countdown { remaining_ms: u64, sampled_at: Time },
|
||||
PausedCountdown { remaining_ms: u64 },
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn remaining_ms(&self, now: Time) -> Option<u64> {
|
||||
match self {
|
||||
Self::Countdown {
|
||||
remaining_ms,
|
||||
sampled_at,
|
||||
} => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))),
|
||||
Self::PausedCountdown { remaining_ms } => Some(*remaining_ms),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
content = "data",
|
||||
rename_all = "kebab-case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Template {
|
||||
Progress,
|
||||
Timer,
|
||||
Media { artist: String, playing: bool },
|
||||
List { rows: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Source {
|
||||
Publisher,
|
||||
Command,
|
||||
Media,
|
||||
Capture,
|
||||
Download,
|
||||
Timer,
|
||||
Notification,
|
||||
Process,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum State {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Data {
|
||||
pub source: Source,
|
||||
pub output: Option<String>,
|
||||
pub priority: u8,
|
||||
pub icon: String,
|
||||
pub title: String,
|
||||
pub value: Value,
|
||||
pub template: Template,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn validate(&mut self, now: Time) -> Result<(), &'static str> {
|
||||
text(&self.title)?;
|
||||
if self
|
||||
.output
|
||||
.as_ref()
|
||||
.is_some_and(|output| text(output).is_err())
|
||||
{
|
||||
return Err("invalid output name");
|
||||
}
|
||||
if self.icon.len() > 64
|
||||
|| !self
|
||||
.icon
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
{
|
||||
return Err("icon must be a symbolic name");
|
||||
}
|
||||
if !matches!(
|
||||
self.icon.as_str(),
|
||||
"hammer"
|
||||
| "timer"
|
||||
| "download"
|
||||
| "media"
|
||||
| "microphone"
|
||||
| "camera"
|
||||
| "call"
|
||||
| "notification"
|
||||
| "ferese"
|
||||
) {
|
||||
self.icon = "ferese".into();
|
||||
}
|
||||
match &mut self.value {
|
||||
Value::Progress(progress)
|
||||
if !progress.is_finite() || !(0.0..=1.0).contains(progress) =>
|
||||
{
|
||||
return Err("progress must be finite and between zero and one");
|
||||
}
|
||||
Value::Text(value) => text(value)?,
|
||||
Value::PausedCountdown { remaining_ms } if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 => {
|
||||
return Err("countdown exceeds seven days");
|
||||
}
|
||||
Value::Countdown {
|
||||
remaining_ms,
|
||||
sampled_at,
|
||||
} => {
|
||||
if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 {
|
||||
return Err("countdown exceeds seven days");
|
||||
}
|
||||
*sampled_at = now;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &self.template {
|
||||
Template::Timer
|
||||
if !matches!(
|
||||
self.value,
|
||||
Value::Countdown { .. } | Value::PausedCountdown { .. }
|
||||
) =>
|
||||
{
|
||||
return Err("timer requires a countdown");
|
||||
}
|
||||
Template::Progress
|
||||
if matches!(
|
||||
self.value,
|
||||
Value::Countdown { .. } | Value::PausedCountdown { .. }
|
||||
) =>
|
||||
{
|
||||
return Err("countdown requires timer template");
|
||||
}
|
||||
Template::Media { artist, .. } => {
|
||||
text(artist)?;
|
||||
if !matches!(
|
||||
self.value,
|
||||
Value::Text(_) | Value::Indeterminate | Value::Progress(_)
|
||||
) {
|
||||
return Err("invalid media value");
|
||||
}
|
||||
}
|
||||
Template::List { rows } => {
|
||||
if rows.len() > 8 {
|
||||
return Err("list exceeds eight rows");
|
||||
}
|
||||
for row in rows {
|
||||
text(row)?;
|
||||
}
|
||||
if !matches!(self.value, Value::Text(_) | Value::Indeterminate) {
|
||||
return Err("invalid list value");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: &str) -> Result<(), &'static str> {
|
||||
if value.len() > MAX_TEXT || value.chars().any(char::is_control) {
|
||||
Err("text exceeds its budget or contains control characters")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Limits {
|
||||
pub max_visible: usize,
|
||||
pub show_badge: bool,
|
||||
pub max_total: usize,
|
||||
pub owner_ceiling: u8,
|
||||
pub start_delay_ms: u64,
|
||||
pub result_flash_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_visible: 2,
|
||||
show_badge: true,
|
||||
max_total: 128,
|
||||
owner_ceiling: 160,
|
||||
start_delay_ms: 300,
|
||||
result_flash_ms: 1_500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Activity {
|
||||
pub id: Id,
|
||||
pub data: Data,
|
||||
pub state: State,
|
||||
pub created_at: Time,
|
||||
pub eligible_at: Time,
|
||||
pub refreshed_at: Time,
|
||||
pub lease_deadline: Option<Time>,
|
||||
pub result_deadline: Option<Time>,
|
||||
pub result_message: Option<String>,
|
||||
pub flash_on_fail: bool,
|
||||
pub result_flash_ms: u64,
|
||||
/// A presentation group only; every invocation retains its own lifetime.
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn eligible(&self, now: Time, dnd: bool) -> bool {
|
||||
now >= self.eligible_at
|
||||
&& match self.state {
|
||||
State::Running => self.lease_deadline.is_none_or(|deadline| now < deadline),
|
||||
State::Done | State::Failed => {
|
||||
!dnd && self.result_deadline.is_some_and(|deadline| now < deadline)
|
||||
}
|
||||
State::Cancelled => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn score(&self, now: Time, ceiling: u8) -> u16 {
|
||||
u16::from(self.data.priority.min(ceiling))
|
||||
+ if now < self.eligible_at.saturating_add(BOOST_MS) {
|
||||
30
|
||||
} else {
|
||||
0
|
||||
}
|
||||
+ match self.state {
|
||||
State::Failed => 100,
|
||||
State::Done => 40,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Store {
|
||||
pub limits: Limits,
|
||||
session: u64,
|
||||
generation: u64,
|
||||
entries: BTreeMap<Id, Activity>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(session: u64) -> Self {
|
||||
Self {
|
||||
limits: Limits::default(),
|
||||
session,
|
||||
generation: 0,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self) -> u64 {
|
||||
self.session
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
|
||||
self.entries.values()
|
||||
}
|
||||
|
||||
pub fn resolve(&self, owner: Owner, local: &str) -> Option<&Id> {
|
||||
self.entries
|
||||
.keys()
|
||||
.find(|id| id.owner == owner && id.local == local)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
local: String,
|
||||
mut data: Data,
|
||||
now: Time,
|
||||
min_duration_ms: u64,
|
||||
lease_ms: Option<u64>,
|
||||
monitored: bool,
|
||||
flash_on_fail: bool,
|
||||
group: Option<String>,
|
||||
) -> Result<Id, &'static str> {
|
||||
self.advance(now);
|
||||
text(&local)?;
|
||||
if local.is_empty() {
|
||||
return Err("activity ID must not be empty");
|
||||
}
|
||||
data.validate(now)?;
|
||||
validate_lease(lease_ms, monitored)?;
|
||||
if self.resolve(owner, &local).is_some() {
|
||||
return Err("duplicate activity ID");
|
||||
}
|
||||
if self.entries.len() >= self.limits.max_total {
|
||||
return Err("session activity quota exceeded");
|
||||
}
|
||||
if self.entries.keys().filter(|id| id.owner == owner).count() >= 3 {
|
||||
return Err("owner activity quota exceeded");
|
||||
}
|
||||
self.generation = self
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.ok_or("generation exhausted")?;
|
||||
let id = Id {
|
||||
owner,
|
||||
local,
|
||||
generation: self.generation,
|
||||
session: self.session,
|
||||
};
|
||||
self.entries.insert(
|
||||
id.clone(),
|
||||
Activity {
|
||||
id: id.clone(),
|
||||
data,
|
||||
state: State::Running,
|
||||
created_at: now,
|
||||
eligible_at: now.saturating_add(self.limits.start_delay_ms.max(min_duration_ms)),
|
||||
refreshed_at: now,
|
||||
lease_deadline: lease_ms.map(|ms| now.saturating_add(ms)),
|
||||
result_deadline: None,
|
||||
result_message: None,
|
||||
flash_on_fail,
|
||||
result_flash_ms: self.limits.result_flash_ms,
|
||||
group,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn entry(&mut self, owner: Owner, id: &Id) -> Result<&mut Activity, &'static str> {
|
||||
if id.session != self.session {
|
||||
return Err("stale activity session; register again");
|
||||
}
|
||||
if id.owner != owner {
|
||||
return Err("activity owner rejected");
|
||||
}
|
||||
self.entries.get_mut(id).ok_or("stale activity generation")
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
id: &Id,
|
||||
value: Option<Value>,
|
||||
lease_ms: Option<u64>,
|
||||
now: Time,
|
||||
) -> Result<(), &'static str> {
|
||||
self.advance(now);
|
||||
validate_lease(lease_ms, true)?;
|
||||
let entry = self.entry(owner, id)?;
|
||||
if entry.state != State::Running {
|
||||
return Err("activity already completed");
|
||||
}
|
||||
if let Some(value) = value {
|
||||
let mut candidate = entry.data.clone();
|
||||
candidate.value = value;
|
||||
candidate.validate(now)?;
|
||||
entry.data = candidate;
|
||||
}
|
||||
if let Some(ms) = lease_ms {
|
||||
validate_lease(Some(ms), true)?;
|
||||
entry.lease_deadline = Some(now.saturating_add(ms));
|
||||
} else if let Some(deadline) = entry.lease_deadline {
|
||||
entry.lease_deadline =
|
||||
Some(now.saturating_add(deadline.saturating_sub(entry.refreshed_at)));
|
||||
}
|
||||
entry.refreshed_at = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
id: &Id,
|
||||
state: State,
|
||||
message: Option<String>,
|
||||
now: Time,
|
||||
) -> Result<(), &'static str> {
|
||||
self.advance(now);
|
||||
if state == State::Running {
|
||||
return Err("finish requires a terminal result");
|
||||
}
|
||||
if let Some(message) = &message {
|
||||
text(message)?;
|
||||
}
|
||||
let entry = self.entry(owner, id)?;
|
||||
if entry.state != State::Running {
|
||||
return if entry.state == state {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("terminal result already accepted")
|
||||
};
|
||||
}
|
||||
if state == State::Cancelled
|
||||
|| now < entry.eligible_at
|
||||
|| (state == State::Failed && !entry.flash_on_fail)
|
||||
{
|
||||
self.entries.remove(id);
|
||||
return Ok(());
|
||||
}
|
||||
entry.state = state;
|
||||
if let Some(message) = message {
|
||||
entry.result_message = Some(message);
|
||||
}
|
||||
entry.result_deadline = Some(now.saturating_add(entry.result_flash_ms));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn owner_exit(&mut self, owner: Owner) {
|
||||
self.entries
|
||||
.retain(|id, entry| id.owner != owner || entry.state != State::Running);
|
||||
}
|
||||
|
||||
pub fn remove_output(&mut self, output: &str, fallback: Option<&str>) {
|
||||
for entry in self
|
||||
.entries
|
||||
.values_mut()
|
||||
.filter(|entry| entry.data.output.as_deref() == Some(output))
|
||||
{
|
||||
entry.data.output = fallback.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attach_output(&mut self, output: &str) -> bool {
|
||||
let mut changed = false;
|
||||
for entry in self
|
||||
.entries
|
||||
.values_mut()
|
||||
.filter(|entry| entry.data.output.is_none())
|
||||
{
|
||||
entry.data.output = Some(output.into());
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn advance(&mut self, now: Time) -> bool {
|
||||
let before = self.entries.len();
|
||||
self.entries.retain(|_, entry| match entry.state {
|
||||
State::Running => entry.lease_deadline.is_none_or(|deadline| now < deadline),
|
||||
_ => entry.result_deadline.is_some_and(|deadline| now < deadline),
|
||||
});
|
||||
before != self.entries.len()
|
||||
}
|
||||
|
||||
pub fn next_deadline(&self, now: Time) -> Option<Time> {
|
||||
self.entries
|
||||
.values()
|
||||
.flat_map(|entry| {
|
||||
[
|
||||
Some(entry.eligible_at),
|
||||
Some(entry.eligible_at.saturating_add(BOOST_MS)),
|
||||
entry.lease_deadline,
|
||||
entry.result_deadline,
|
||||
entry.result_deadline.map(|deadline| {
|
||||
deadline
|
||||
.saturating_sub(entry.result_flash_ms)
|
||||
.saturating_add(400)
|
||||
}),
|
||||
]
|
||||
})
|
||||
.flatten()
|
||||
.filter(|deadline| *deadline > now)
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_lease(lease: Option<u64>, monitored: bool) -> Result<(), &'static str> {
|
||||
match lease {
|
||||
Some(0) => Err("lease must be positive"),
|
||||
Some(ms) if ms > MAX_LEASE_MS => Err("lease exceeds five minutes"),
|
||||
None if !monitored => Err("unmonitored owner requires a lease"),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recording/battery occupy a separate host-only class. Notifications always
|
||||
/// follow every other ordinary source, even when a result boost is active.
|
||||
pub fn ranked<'a>(
|
||||
entries: impl IntoIterator<Item = &'a Activity>,
|
||||
now: Time,
|
||||
limits: Limits,
|
||||
output: &str,
|
||||
dnd: bool,
|
||||
) -> Vec<&'a Activity> {
|
||||
let mut entries: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.eligible(now, dnd) && entry.data.output.as_deref() == Some(output))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
(a.data.source == Source::Notification)
|
||||
.cmp(&(b.data.source == Source::Notification))
|
||||
.then_with(|| {
|
||||
b.score(now, limits.owner_ceiling)
|
||||
.cmp(&a.score(now, limits.owner_ceiling))
|
||||
})
|
||||
.then_with(|| a.eligible_at.cmp(&b.eligible_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Selection {
|
||||
pinned: Option<Vec<Id>>,
|
||||
pub visible: Vec<Id>,
|
||||
pub hidden: usize,
|
||||
policy: Option<(bool, usize, usize)>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn enter(&mut self) {
|
||||
self.pinned = Some(self.visible.clone());
|
||||
}
|
||||
pub fn leave(&mut self) {
|
||||
self.pinned = None;
|
||||
}
|
||||
pub fn override_pin(&mut self) {
|
||||
self.pinned = None;
|
||||
}
|
||||
pub fn select(
|
||||
&mut self,
|
||||
ranked: &[&Activity],
|
||||
limits: Limits,
|
||||
dnd: bool,
|
||||
protected_slots: usize,
|
||||
) {
|
||||
let policy = (dnd, protected_slots, limits.max_visible);
|
||||
if self.policy != Some(policy) {
|
||||
self.override_pin();
|
||||
self.policy = Some(policy);
|
||||
}
|
||||
let count = limits
|
||||
.max_visible
|
||||
.clamp(1, 2)
|
||||
.saturating_sub(protected_slots)
|
||||
.min(if dnd { 1 } else { 2 });
|
||||
let eligible: Vec<_> = ranked
|
||||
.iter()
|
||||
.filter(|entry| !dnd || entry.state == State::Running)
|
||||
.collect();
|
||||
let mut groups = std::collections::BTreeSet::new();
|
||||
let ids: Vec<_> = eligible
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
let Some(group) = &entry.group else {
|
||||
return true;
|
||||
};
|
||||
if entry.state != State::Running
|
||||
&& eligible.iter().any(|other| {
|
||||
other.id.owner == entry.id.owner
|
||||
&& other.group.as_ref() == Some(group)
|
||||
&& other.state == State::Running
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
groups.insert((entry.id.owner, group.clone()))
|
||||
})
|
||||
.map(|entry| entry.id.clone())
|
||||
.collect();
|
||||
self.visible = match &mut self.pinned {
|
||||
Some(pinned) => {
|
||||
pinned.retain(|id| ids.contains(id));
|
||||
pinned.iter().take(count).cloned().collect()
|
||||
}
|
||||
None => ids.iter().take(count).cloned().collect(),
|
||||
};
|
||||
self.hidden = if dnd || !limits.show_badge {
|
||||
0
|
||||
} else {
|
||||
eligible.len().saturating_sub(self.visible.len())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus survives reordered/updated rows by identity; removal chooses the next
|
||||
/// surviving row at the former position.
|
||||
pub fn reconcile_focus(focus: &mut Option<Id>, previous: &[Id], current: &[Id]) {
|
||||
if focus.as_ref().is_some_and(|id| current.contains(id)) {
|
||||
return;
|
||||
}
|
||||
let index = focus
|
||||
.as_ref()
|
||||
.and_then(|id| previous.iter().position(|old| old == id))
|
||||
.unwrap_or(0);
|
||||
*focus = current
|
||||
.get(index.min(current.len().saturating_sub(1)))
|
||||
.cloned();
|
||||
}
|
||||
pub use store::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,624 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type Time = u64;
|
||||
const BOOST_MS: u64 = 5_000;
|
||||
pub const MAX_TEXT: usize = 256;
|
||||
pub const MAX_LEASE_MS: u64 = 300_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Owner {
|
||||
pub pid: u32,
|
||||
pub birth: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Id {
|
||||
pub owner: Owner,
|
||||
pub local: String,
|
||||
pub generation: u64,
|
||||
pub session: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
content = "value",
|
||||
rename_all = "kebab-case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Value {
|
||||
Indeterminate,
|
||||
Progress(f32),
|
||||
Text(String),
|
||||
Countdown { remaining_ms: u64, sampled_at: Time },
|
||||
PausedCountdown { remaining_ms: u64 },
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn remaining_ms(&self, now: Time) -> Option<u64> {
|
||||
match self {
|
||||
Self::Countdown {
|
||||
remaining_ms,
|
||||
sampled_at,
|
||||
} => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))),
|
||||
Self::PausedCountdown { remaining_ms } => Some(*remaining_ms),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
content = "data",
|
||||
rename_all = "kebab-case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Template {
|
||||
Progress,
|
||||
Timer,
|
||||
Media { artist: String, playing: bool },
|
||||
List { rows: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Source {
|
||||
Publisher,
|
||||
Command,
|
||||
Media,
|
||||
Capture,
|
||||
Download,
|
||||
Timer,
|
||||
Notification,
|
||||
Process,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum State {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Data {
|
||||
pub source: Source,
|
||||
pub output: Option<String>,
|
||||
pub priority: u8,
|
||||
pub icon: String,
|
||||
pub title: String,
|
||||
pub value: Value,
|
||||
pub template: Template,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn validate(&mut self, now: Time) -> Result<(), &'static str> {
|
||||
text(&self.title)?;
|
||||
if self
|
||||
.output
|
||||
.as_ref()
|
||||
.is_some_and(|output| text(output).is_err())
|
||||
{
|
||||
return Err("invalid output name");
|
||||
}
|
||||
if self.icon.len() > 64
|
||||
|| !self
|
||||
.icon
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
{
|
||||
return Err("icon must be a symbolic name");
|
||||
}
|
||||
if !matches!(
|
||||
self.icon.as_str(),
|
||||
"hammer"
|
||||
| "timer"
|
||||
| "download"
|
||||
| "media"
|
||||
| "microphone"
|
||||
| "camera"
|
||||
| "call"
|
||||
| "notification"
|
||||
| "ferese"
|
||||
) {
|
||||
self.icon = "ferese".into();
|
||||
}
|
||||
match &mut self.value {
|
||||
Value::Progress(progress)
|
||||
if !progress.is_finite() || !(0.0..=1.0).contains(progress) =>
|
||||
{
|
||||
return Err("progress must be finite and between zero and one");
|
||||
}
|
||||
Value::Text(value) => text(value)?,
|
||||
Value::PausedCountdown { remaining_ms } if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 => {
|
||||
return Err("countdown exceeds seven days");
|
||||
}
|
||||
Value::Countdown {
|
||||
remaining_ms,
|
||||
sampled_at,
|
||||
} => {
|
||||
if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 {
|
||||
return Err("countdown exceeds seven days");
|
||||
}
|
||||
*sampled_at = now;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &self.template {
|
||||
Template::Timer
|
||||
if !matches!(
|
||||
self.value,
|
||||
Value::Countdown { .. } | Value::PausedCountdown { .. }
|
||||
) =>
|
||||
{
|
||||
return Err("timer requires a countdown");
|
||||
}
|
||||
Template::Progress
|
||||
if matches!(
|
||||
self.value,
|
||||
Value::Countdown { .. } | Value::PausedCountdown { .. }
|
||||
) =>
|
||||
{
|
||||
return Err("countdown requires timer template");
|
||||
}
|
||||
Template::Media { artist, .. } => {
|
||||
text(artist)?;
|
||||
if !matches!(
|
||||
self.value,
|
||||
Value::Text(_) | Value::Indeterminate | Value::Progress(_)
|
||||
) {
|
||||
return Err("invalid media value");
|
||||
}
|
||||
}
|
||||
Template::List { rows } => {
|
||||
if rows.len() > 8 {
|
||||
return Err("list exceeds eight rows");
|
||||
}
|
||||
for row in rows {
|
||||
text(row)?;
|
||||
}
|
||||
if !matches!(self.value, Value::Text(_) | Value::Indeterminate) {
|
||||
return Err("invalid list value");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: &str) -> Result<(), &'static str> {
|
||||
if value.len() > MAX_TEXT || value.chars().any(char::is_control) {
|
||||
Err("text exceeds its budget or contains control characters")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Limits {
|
||||
pub max_visible: usize,
|
||||
pub show_badge: bool,
|
||||
pub max_total: usize,
|
||||
pub owner_ceiling: u8,
|
||||
pub start_delay_ms: u64,
|
||||
pub result_flash_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_visible: 2,
|
||||
show_badge: true,
|
||||
max_total: 128,
|
||||
owner_ceiling: 160,
|
||||
start_delay_ms: 300,
|
||||
result_flash_ms: 1_500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Activity {
|
||||
pub id: Id,
|
||||
pub data: Data,
|
||||
pub state: State,
|
||||
pub created_at: Time,
|
||||
pub eligible_at: Time,
|
||||
pub refreshed_at: Time,
|
||||
pub lease_deadline: Option<Time>,
|
||||
pub result_deadline: Option<Time>,
|
||||
pub result_message: Option<String>,
|
||||
pub flash_on_fail: bool,
|
||||
pub result_flash_ms: u64,
|
||||
/// A presentation group only; every invocation retains its own lifetime.
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn eligible(&self, now: Time, dnd: bool) -> bool {
|
||||
now >= self.eligible_at
|
||||
&& match self.state {
|
||||
State::Running => self.lease_deadline.is_none_or(|deadline| now < deadline),
|
||||
State::Done | State::Failed => {
|
||||
!dnd && self.result_deadline.is_some_and(|deadline| now < deadline)
|
||||
}
|
||||
State::Cancelled => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn score(&self, now: Time, ceiling: u8) -> u16 {
|
||||
u16::from(self.data.priority.min(ceiling))
|
||||
+ if now < self.eligible_at.saturating_add(BOOST_MS) {
|
||||
30
|
||||
} else {
|
||||
0
|
||||
}
|
||||
+ match self.state {
|
||||
State::Failed => 100,
|
||||
State::Done => 40,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Store {
|
||||
pub limits: Limits,
|
||||
session: u64,
|
||||
generation: u64,
|
||||
entries: BTreeMap<Id, Activity>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(session: u64) -> Self {
|
||||
Self {
|
||||
limits: Limits::default(),
|
||||
session,
|
||||
generation: 0,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self) -> u64 {
|
||||
self.session
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
|
||||
self.entries.values()
|
||||
}
|
||||
|
||||
pub fn resolve(&self, owner: Owner, local: &str) -> Option<&Id> {
|
||||
self.entries
|
||||
.keys()
|
||||
.find(|id| id.owner == owner && id.local == local)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
local: String,
|
||||
mut data: Data,
|
||||
now: Time,
|
||||
min_duration_ms: u64,
|
||||
lease_ms: Option<u64>,
|
||||
monitored: bool,
|
||||
flash_on_fail: bool,
|
||||
group: Option<String>,
|
||||
) -> Result<Id, &'static str> {
|
||||
self.advance(now);
|
||||
text(&local)?;
|
||||
if local.is_empty() {
|
||||
return Err("activity ID must not be empty");
|
||||
}
|
||||
data.validate(now)?;
|
||||
validate_lease(lease_ms, monitored)?;
|
||||
if self.resolve(owner, &local).is_some() {
|
||||
return Err("duplicate activity ID");
|
||||
}
|
||||
if self.entries.len() >= self.limits.max_total {
|
||||
return Err("session activity quota exceeded");
|
||||
}
|
||||
if self.entries.keys().filter(|id| id.owner == owner).count() >= 3 {
|
||||
return Err("owner activity quota exceeded");
|
||||
}
|
||||
self.generation = self
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.ok_or("generation exhausted")?;
|
||||
let id = Id {
|
||||
owner,
|
||||
local,
|
||||
generation: self.generation,
|
||||
session: self.session,
|
||||
};
|
||||
self.entries.insert(
|
||||
id.clone(),
|
||||
Activity {
|
||||
id: id.clone(),
|
||||
data,
|
||||
state: State::Running,
|
||||
created_at: now,
|
||||
eligible_at: now.saturating_add(self.limits.start_delay_ms.max(min_duration_ms)),
|
||||
refreshed_at: now,
|
||||
lease_deadline: lease_ms.map(|ms| now.saturating_add(ms)),
|
||||
result_deadline: None,
|
||||
result_message: None,
|
||||
flash_on_fail,
|
||||
result_flash_ms: self.limits.result_flash_ms,
|
||||
group,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn entry(&mut self, owner: Owner, id: &Id) -> Result<&mut Activity, &'static str> {
|
||||
if id.session != self.session {
|
||||
return Err("stale activity session; register again");
|
||||
}
|
||||
if id.owner != owner {
|
||||
return Err("activity owner rejected");
|
||||
}
|
||||
self.entries.get_mut(id).ok_or("stale activity generation")
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
id: &Id,
|
||||
value: Option<Value>,
|
||||
lease_ms: Option<u64>,
|
||||
now: Time,
|
||||
) -> Result<(), &'static str> {
|
||||
self.advance(now);
|
||||
validate_lease(lease_ms, true)?;
|
||||
let entry = self.entry(owner, id)?;
|
||||
if entry.state != State::Running {
|
||||
return Err("activity already completed");
|
||||
}
|
||||
if let Some(value) = value {
|
||||
let mut candidate = entry.data.clone();
|
||||
candidate.value = value;
|
||||
candidate.validate(now)?;
|
||||
entry.data = candidate;
|
||||
}
|
||||
if let Some(ms) = lease_ms {
|
||||
validate_lease(Some(ms), true)?;
|
||||
entry.lease_deadline = Some(now.saturating_add(ms));
|
||||
} else if let Some(deadline) = entry.lease_deadline {
|
||||
entry.lease_deadline =
|
||||
Some(now.saturating_add(deadline.saturating_sub(entry.refreshed_at)));
|
||||
}
|
||||
entry.refreshed_at = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
id: &Id,
|
||||
state: State,
|
||||
message: Option<String>,
|
||||
now: Time,
|
||||
) -> Result<(), &'static str> {
|
||||
self.advance(now);
|
||||
if state == State::Running {
|
||||
return Err("finish requires a terminal result");
|
||||
}
|
||||
if let Some(message) = &message {
|
||||
text(message)?;
|
||||
}
|
||||
let entry = self.entry(owner, id)?;
|
||||
if entry.state != State::Running {
|
||||
return if entry.state == state {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("terminal result already accepted")
|
||||
};
|
||||
}
|
||||
if state == State::Cancelled
|
||||
|| now < entry.eligible_at
|
||||
|| (state == State::Failed && !entry.flash_on_fail)
|
||||
{
|
||||
self.entries.remove(id);
|
||||
return Ok(());
|
||||
}
|
||||
entry.state = state;
|
||||
if let Some(message) = message {
|
||||
entry.result_message = Some(message);
|
||||
}
|
||||
entry.result_deadline = Some(now.saturating_add(entry.result_flash_ms));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn owner_exit(&mut self, owner: Owner) {
|
||||
self.entries
|
||||
.retain(|id, entry| id.owner != owner || entry.state != State::Running);
|
||||
}
|
||||
|
||||
pub fn remove_output(&mut self, output: &str, fallback: Option<&str>) {
|
||||
for entry in self
|
||||
.entries
|
||||
.values_mut()
|
||||
.filter(|entry| entry.data.output.as_deref() == Some(output))
|
||||
{
|
||||
entry.data.output = fallback.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attach_output(&mut self, output: &str) -> bool {
|
||||
let mut changed = false;
|
||||
for entry in self
|
||||
.entries
|
||||
.values_mut()
|
||||
.filter(|entry| entry.data.output.is_none())
|
||||
{
|
||||
entry.data.output = Some(output.into());
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn advance(&mut self, now: Time) -> bool {
|
||||
let before = self.entries.len();
|
||||
self.entries.retain(|_, entry| match entry.state {
|
||||
State::Running => entry.lease_deadline.is_none_or(|deadline| now < deadline),
|
||||
_ => entry.result_deadline.is_some_and(|deadline| now < deadline),
|
||||
});
|
||||
before != self.entries.len()
|
||||
}
|
||||
|
||||
pub fn next_deadline(&self, now: Time) -> Option<Time> {
|
||||
self.entries
|
||||
.values()
|
||||
.flat_map(|entry| {
|
||||
[
|
||||
Some(entry.eligible_at),
|
||||
Some(entry.eligible_at.saturating_add(BOOST_MS)),
|
||||
entry.lease_deadline,
|
||||
entry.result_deadline,
|
||||
entry.result_deadline.map(|deadline| {
|
||||
deadline
|
||||
.saturating_sub(entry.result_flash_ms)
|
||||
.saturating_add(400)
|
||||
}),
|
||||
]
|
||||
})
|
||||
.flatten()
|
||||
.filter(|deadline| *deadline > now)
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_lease(lease: Option<u64>, monitored: bool) -> Result<(), &'static str> {
|
||||
match lease {
|
||||
Some(0) => Err("lease must be positive"),
|
||||
Some(ms) if ms > MAX_LEASE_MS => Err("lease exceeds five minutes"),
|
||||
None if !monitored => Err("unmonitored owner requires a lease"),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recording/battery occupy a separate host-only class. Notifications always
|
||||
/// follow every other ordinary source, even when a result boost is active.
|
||||
pub fn ranked<'a>(
|
||||
entries: impl IntoIterator<Item = &'a Activity>,
|
||||
now: Time,
|
||||
limits: Limits,
|
||||
output: &str,
|
||||
dnd: bool,
|
||||
) -> Vec<&'a Activity> {
|
||||
let mut entries: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.eligible(now, dnd) && entry.data.output.as_deref() == Some(output))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
(a.data.source == Source::Notification)
|
||||
.cmp(&(b.data.source == Source::Notification))
|
||||
.then_with(|| {
|
||||
b.score(now, limits.owner_ceiling)
|
||||
.cmp(&a.score(now, limits.owner_ceiling))
|
||||
})
|
||||
.then_with(|| a.eligible_at.cmp(&b.eligible_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Selection {
|
||||
pinned: Option<Vec<Id>>,
|
||||
pub visible: Vec<Id>,
|
||||
pub hidden: usize,
|
||||
policy: Option<(bool, usize, usize)>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn enter(&mut self) {
|
||||
self.pinned = Some(self.visible.clone());
|
||||
}
|
||||
pub fn leave(&mut self) {
|
||||
self.pinned = None;
|
||||
}
|
||||
pub fn override_pin(&mut self) {
|
||||
self.pinned = None;
|
||||
}
|
||||
pub fn select(
|
||||
&mut self,
|
||||
ranked: &[&Activity],
|
||||
limits: Limits,
|
||||
dnd: bool,
|
||||
protected_slots: usize,
|
||||
) {
|
||||
let policy = (dnd, protected_slots, limits.max_visible);
|
||||
if self.policy != Some(policy) {
|
||||
self.override_pin();
|
||||
self.policy = Some(policy);
|
||||
}
|
||||
let count = limits
|
||||
.max_visible
|
||||
.clamp(1, 2)
|
||||
.saturating_sub(protected_slots)
|
||||
.min(if dnd { 1 } else { 2 });
|
||||
let eligible: Vec<_> = ranked
|
||||
.iter()
|
||||
.filter(|entry| !dnd || entry.state == State::Running)
|
||||
.collect();
|
||||
let mut groups = std::collections::BTreeSet::new();
|
||||
let ids: Vec<_> = eligible
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
let Some(group) = &entry.group else {
|
||||
return true;
|
||||
};
|
||||
if entry.state != State::Running
|
||||
&& eligible.iter().any(|other| {
|
||||
other.id.owner == entry.id.owner
|
||||
&& other.group.as_ref() == Some(group)
|
||||
&& other.state == State::Running
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
groups.insert((entry.id.owner, group.clone()))
|
||||
})
|
||||
.map(|entry| entry.id.clone())
|
||||
.collect();
|
||||
self.visible = match &mut self.pinned {
|
||||
Some(pinned) => {
|
||||
pinned.retain(|id| ids.contains(id));
|
||||
pinned.iter().take(count).cloned().collect()
|
||||
}
|
||||
None => ids.iter().take(count).cloned().collect(),
|
||||
};
|
||||
self.hidden = if dnd || !limits.show_badge {
|
||||
0
|
||||
} else {
|
||||
eligible.len().saturating_sub(self.visible.len())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus survives reordered/updated rows by identity; removal chooses the next
|
||||
/// surviving row at the former position.
|
||||
pub fn reconcile_focus(focus: &mut Option<Id>, previous: &[Id], current: &[Id]) {
|
||||
if focus.as_ref().is_some_and(|id| current.contains(id)) {
|
||||
return;
|
||||
}
|
||||
let index = focus
|
||||
.as_ref()
|
||||
.and_then(|id| previous.iter().position(|old| old == id))
|
||||
.unwrap_or(0);
|
||||
*focus = current
|
||||
.get(index.min(current.len().saturating_sub(1)))
|
||||
.cloned();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -1,30 +1,11 @@
|
||||
//! Activity rules are matched in declaration order, followed by unreplaced
|
||||
//! built-ins. Matching metadata is bounded and never stored in the result.
|
||||
use crate::document;
|
||||
use kdl::KdlNode;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Enable installation defaults without overriding an explicit user opt-out.
|
||||
pub fn setup_shell_hook(document: &mut crate::Document) -> Result<bool, crate::Error> {
|
||||
let explicit = document
|
||||
.doc
|
||||
.get("activities")
|
||||
.and_then(kdl::KdlNode::children)
|
||||
.and_then(|sources| sources.get("sources"))
|
||||
.and_then(kdl::KdlNode::children)
|
||||
.and_then(|sources| sources.get("shell-hook"))
|
||||
.and_then(|node| node.get(0))
|
||||
.and_then(kdl::KdlValue::as_bool);
|
||||
if explicit == Some(false) {
|
||||
return Ok(false);
|
||||
}
|
||||
if explicit.is_none() {
|
||||
document.set("activities.sources.shell_hook", json!(true))?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
use crate::Error;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -215,7 +196,7 @@ impl Settings {
|
||||
}
|
||||
|
||||
fn diagnostic(node: &KdlNode, message: impl std::fmt::Display) -> Error {
|
||||
Error(format!(
|
||||
Error::from(format!(
|
||||
"activities at byte {}: {message}",
|
||||
node.span().offset()
|
||||
))
|
||||
@@ -270,11 +251,11 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
|
||||
property
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| crate::scalar(entry.value()))
|
||||
.map(|entry| document::scalar(entry.value()))
|
||||
.collect::<Result<_, _>>()?,
|
||||
)
|
||||
} else {
|
||||
crate::node_value(property, &key, "rule")
|
||||
document::node_value(property, &key, "rule")
|
||||
.map_err(|error| diagnostic(property, error))?
|
||||
};
|
||||
if value.insert(key, parsed).is_some() {
|
||||
@@ -316,7 +297,7 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
|
||||
return Err(diagnostic(child, "unknown activity node"));
|
||||
}
|
||||
let field = key.replace('-', "_");
|
||||
let value = crate::node_value(child, &field, "activities")
|
||||
let value = document::node_value(child, &field, "activities")
|
||||
.map_err(|error| diagnostic(child, error))?;
|
||||
if values.insert(field, value).is_some() {
|
||||
return Err(diagnostic(child, "duplicate activity setting"));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+4
-1151
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,206 @@
|
||||
use std::error::Error;
|
||||
use std::io::{Read, Write};
|
||||
use std::{fmt, io};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub id: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Response {
|
||||
pub version: u32,
|
||||
pub id: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ResponseError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ResponseError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn success(id: u64, result: Value) -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: u64, code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
id,
|
||||
result: None,
|
||||
error: Some(ResponseError {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FrameError {
|
||||
Io(io::Error),
|
||||
Oversized(usize),
|
||||
InvalidJson(serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for FrameError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => error.fmt(formatter),
|
||||
Self::Oversized(size) => write!(
|
||||
formatter,
|
||||
"IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}"
|
||||
),
|
||||
Self::InvalidJson(error) => write!(formatter, "invalid IPC JSON: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for FrameError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::InvalidJson(error) => Some(error),
|
||||
Self::Oversized(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for FrameError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_frame<T: for<'de> Deserialize<'de>>(reader: &mut impl Read) -> Result<T, FrameError> {
|
||||
let mut header = [0_u8; 4];
|
||||
reader.read_exact(&mut header)?;
|
||||
let size = u32::from_be_bytes(header) as usize;
|
||||
if size > MAX_PAYLOAD_SIZE {
|
||||
return Err(FrameError::Oversized(size));
|
||||
}
|
||||
|
||||
let mut payload = vec![0; size];
|
||||
reader.read_exact(&mut payload)?;
|
||||
serde_json::from_slice(&payload).map_err(FrameError::InvalidJson)
|
||||
}
|
||||
|
||||
pub fn write_frame<T: Serialize>(writer: &mut impl Write, value: &T) -> Result<(), FrameError> {
|
||||
let payload = serde_json::to_vec(value).map_err(FrameError::InvalidJson)?;
|
||||
if payload.len() > MAX_PAYLOAD_SIZE {
|
||||
return Err(FrameError::Oversized(payload.len()));
|
||||
}
|
||||
|
||||
writer.write_all(&(payload.len() as u32).to_be_bytes())?;
|
||||
writer.write_all(&payload)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct ChunkedReader<R> {
|
||||
inner: R,
|
||||
chunk_size: usize,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for ChunkedReader<R> {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let limit = buffer.len().min(self.chunk_size);
|
||||
self.inner.read(&mut buffer[..limit])
|
||||
}
|
||||
}
|
||||
|
||||
fn request(id: u64, command: &str) -> Request {
|
||||
Request {
|
||||
version: VERSION,
|
||||
id,
|
||||
kind: "command".to_owned(),
|
||||
command: command.to_owned(),
|
||||
args: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framed_json_round_trips() {
|
||||
let mut request = request(42, "focus");
|
||||
request.args = serde_json::json!({ "direction": "left" });
|
||||
let mut bytes = Vec::new();
|
||||
write_frame(&mut bytes, &request).unwrap();
|
||||
let decoded: Request = read_frame(&mut bytes.as_slice()).unwrap();
|
||||
|
||||
assert_eq!(decoded.version, VERSION);
|
||||
assert_eq!(decoded.id, 42);
|
||||
assert_eq!(decoded.command, "focus");
|
||||
assert_eq!(decoded.args["direction"], "left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_fragmented_frames_and_preserves_frame_boundaries() {
|
||||
let mut bytes = Vec::new();
|
||||
write_frame(&mut bytes, &request(1, "get-workspaces")).unwrap();
|
||||
write_frame(&mut bytes, &request(2, "get-outputs")).unwrap();
|
||||
let mut reader = ChunkedReader {
|
||||
inner: Cursor::new(bytes),
|
||||
chunk_size: 3,
|
||||
};
|
||||
|
||||
let first: Request = read_frame(&mut reader).unwrap();
|
||||
let second: Request = read_frame(&mut reader).unwrap();
|
||||
|
||||
assert_eq!((first.id, first.command.as_str()), (1, "get-workspaces"));
|
||||
assert_eq!((second.id, second.command.as_str()), (2, "get-outputs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_and_truncated_payloads_without_panicking() {
|
||||
let invalid = b"not-json";
|
||||
let mut invalid_frame = Vec::from((invalid.len() as u32).to_be_bytes());
|
||||
invalid_frame.extend_from_slice(invalid);
|
||||
assert!(matches!(
|
||||
read_frame::<Request>(&mut invalid_frame.as_slice()),
|
||||
Err(FrameError::InvalidJson(_))
|
||||
));
|
||||
|
||||
let mut truncated = Vec::from(32_u32.to_be_bytes());
|
||||
truncated.extend_from_slice(b"short");
|
||||
assert!(matches!(
|
||||
read_frame::<Request>(&mut truncated.as_slice()),
|
||||
Err(FrameError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_frames_before_allocating_payload() {
|
||||
let size = (MAX_PAYLOAD_SIZE as u32 + 1).to_be_bytes();
|
||||
let error = read_frame::<Request>(&mut size.as_slice()).unwrap_err();
|
||||
|
||||
assert!(matches!(error, FrameError::Oversized(_)));
|
||||
}
|
||||
}
|
||||
@@ -1,216 +1,13 @@
|
||||
//! Framed IPC messages and the services exposed over compositor connections.
|
||||
pub mod activities;
|
||||
mod connection;
|
||||
pub use connection::{Cancellation, Connection, socket_path};
|
||||
use std::error::Error;
|
||||
use std::io::{Read, Write};
|
||||
use std::{fmt, io};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod events;
|
||||
mod frame;
|
||||
pub mod media;
|
||||
pub mod night_light;
|
||||
pub mod socket;
|
||||
pub mod theme;
|
||||
pub mod xwayland;
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub id: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Response {
|
||||
pub version: u32,
|
||||
pub id: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ResponseError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ResponseError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn success(id: u64, result: Value) -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: u64, code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
id,
|
||||
result: None,
|
||||
error: Some(ResponseError {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FrameError {
|
||||
Io(io::Error),
|
||||
Oversized(usize),
|
||||
InvalidJson(serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for FrameError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => error.fmt(formatter),
|
||||
Self::Oversized(size) => write!(
|
||||
formatter,
|
||||
"IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}"
|
||||
),
|
||||
Self::InvalidJson(error) => write!(formatter, "invalid IPC JSON: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for FrameError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::InvalidJson(error) => Some(error),
|
||||
Self::Oversized(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for FrameError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_frame<T: for<'de> Deserialize<'de>>(reader: &mut impl Read) -> Result<T, FrameError> {
|
||||
let mut header = [0_u8; 4];
|
||||
reader.read_exact(&mut header)?;
|
||||
let size = u32::from_be_bytes(header) as usize;
|
||||
if size > MAX_PAYLOAD_SIZE {
|
||||
return Err(FrameError::Oversized(size));
|
||||
}
|
||||
|
||||
let mut payload = vec![0; size];
|
||||
reader.read_exact(&mut payload)?;
|
||||
serde_json::from_slice(&payload).map_err(FrameError::InvalidJson)
|
||||
}
|
||||
|
||||
pub fn write_frame<T: Serialize>(writer: &mut impl Write, value: &T) -> Result<(), FrameError> {
|
||||
let payload = serde_json::to_vec(value).map_err(FrameError::InvalidJson)?;
|
||||
if payload.len() > MAX_PAYLOAD_SIZE {
|
||||
return Err(FrameError::Oversized(payload.len()));
|
||||
}
|
||||
|
||||
writer.write_all(&(payload.len() as u32).to_be_bytes())?;
|
||||
writer.write_all(&payload)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct ChunkedReader<R> {
|
||||
inner: R,
|
||||
chunk_size: usize,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for ChunkedReader<R> {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let limit = buffer.len().min(self.chunk_size);
|
||||
self.inner.read(&mut buffer[..limit])
|
||||
}
|
||||
}
|
||||
|
||||
fn request(id: u64, command: &str) -> Request {
|
||||
Request {
|
||||
version: VERSION,
|
||||
id,
|
||||
kind: "command".to_owned(),
|
||||
command: command.to_owned(),
|
||||
args: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framed_json_round_trips() {
|
||||
let mut request = request(42, "focus");
|
||||
request.args = serde_json::json!({ "direction": "left" });
|
||||
let mut bytes = Vec::new();
|
||||
write_frame(&mut bytes, &request).unwrap();
|
||||
let decoded: Request = read_frame(&mut bytes.as_slice()).unwrap();
|
||||
|
||||
assert_eq!(decoded.version, VERSION);
|
||||
assert_eq!(decoded.id, 42);
|
||||
assert_eq!(decoded.command, "focus");
|
||||
assert_eq!(decoded.args["direction"], "left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_fragmented_frames_and_preserves_frame_boundaries() {
|
||||
let mut bytes = Vec::new();
|
||||
write_frame(&mut bytes, &request(1, "get-workspaces")).unwrap();
|
||||
write_frame(&mut bytes, &request(2, "get-outputs")).unwrap();
|
||||
let mut reader = ChunkedReader {
|
||||
inner: Cursor::new(bytes),
|
||||
chunk_size: 3,
|
||||
};
|
||||
|
||||
let first: Request = read_frame(&mut reader).unwrap();
|
||||
let second: Request = read_frame(&mut reader).unwrap();
|
||||
|
||||
assert_eq!((first.id, first.command.as_str()), (1, "get-workspaces"));
|
||||
assert_eq!((second.id, second.command.as_str()), (2, "get-outputs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_and_truncated_payloads_without_panicking() {
|
||||
let invalid = b"not-json";
|
||||
let mut invalid_frame = Vec::from((invalid.len() as u32).to_be_bytes());
|
||||
invalid_frame.extend_from_slice(invalid);
|
||||
assert!(matches!(
|
||||
read_frame::<Request>(&mut invalid_frame.as_slice()),
|
||||
Err(FrameError::InvalidJson(_))
|
||||
));
|
||||
|
||||
let mut truncated = Vec::from(32_u32.to_be_bytes());
|
||||
truncated.extend_from_slice(b"short");
|
||||
assert!(matches!(
|
||||
read_frame::<Request>(&mut truncated.as_slice()),
|
||||
Err(FrameError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_frames_before_allocating_payload() {
|
||||
let size = (MAX_PAYLOAD_SIZE as u32 + 1).to_be_bytes();
|
||||
let error = read_frame::<Request>(&mut size.as_slice()).unwrap_err();
|
||||
|
||||
assert!(matches!(error, FrameError::Oversized(_)));
|
||||
}
|
||||
}
|
||||
pub use connection::{Cancellation, Connection, socket_path};
|
||||
pub use frame::*;
|
||||
File diff suppressed because it is too large.
Load diff
+3
-1930
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,9 @@
|
||||
use crate::layout_tree;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect,
|
||||
SizeConstraints, WindowId, normalized_constraints, record_minimum_warnings,
|
||||
SizeConstraints, WindowId,
|
||||
};
|
||||
|
||||
const DEFAULT_WIDTH: f64 = 0.5;
|
||||
@@ -670,7 +671,7 @@ impl ScrollingLayout {
|
||||
constraints
|
||||
.get(&window)
|
||||
.copied()
|
||||
.map(normalized_constraints)
|
||||
.map(layout_tree::normalized_constraints)
|
||||
.unwrap_or_default()
|
||||
.max_width
|
||||
.map(|maximum| {
|
||||
@@ -678,7 +679,7 @@ impl ScrollingLayout {
|
||||
constraints
|
||||
.get(&window)
|
||||
.copied()
|
||||
.map(normalized_constraints)
|
||||
.map(layout_tree::normalized_constraints)
|
||||
.unwrap_or_default()
|
||||
.min_width,
|
||||
)
|
||||
@@ -854,7 +855,7 @@ impl ScrollingLayout {
|
||||
constraints
|
||||
.get(window)
|
||||
.copied()
|
||||
.map(normalized_constraints)
|
||||
.map(layout_tree::normalized_constraints)
|
||||
.unwrap_or_default()
|
||||
.min_width
|
||||
})
|
||||
@@ -948,8 +949,9 @@ impl ScrollingLayout {
|
||||
// Keep the column allocation stable and honor compatible maximums.
|
||||
let mut common = SizeConstraints::default();
|
||||
for id in &column.windows {
|
||||
let c =
|
||||
normalized_constraints(constraints.get(id).copied().unwrap_or_default());
|
||||
let c = layout_tree::normalized_constraints(
|
||||
constraints.get(id).copied().unwrap_or_default(),
|
||||
);
|
||||
common.min_width = common.min_width.max(c.min_width);
|
||||
common.min_height = common.min_height.max(c.min_height);
|
||||
common.max_width = match (common.max_width, c.max_width) {
|
||||
@@ -961,8 +963,8 @@ impl ScrollingLayout {
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
}
|
||||
let common = normalized_constraints(common);
|
||||
record_minimum_warnings(window, rect, common, &mut result.warnings);
|
||||
let common = layout_tree::normalized_constraints(common);
|
||||
layout_tree::record_minimum_warnings(window, rect, common, &mut result.warnings);
|
||||
apply_maximums(window, &mut rect, common, &mut result.warnings);
|
||||
if column.windows.len() > 1 {
|
||||
result.tab_bars.push(crate::TabBar {
|
||||
@@ -984,7 +986,9 @@ impl ScrollingLayout {
|
||||
.windows
|
||||
.iter()
|
||||
.map(|window| {
|
||||
normalized_constraints(constraints.get(window).copied().unwrap_or_default())
|
||||
layout_tree::normalized_constraints(
|
||||
constraints.get(window).copied().unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let row_heights = constrained_row_heights(column, available_height, &row_constraints);
|
||||
@@ -998,7 +1002,12 @@ impl ScrollingLayout {
|
||||
row_heights[window_index],
|
||||
);
|
||||
let constraint = row_constraints[window_index];
|
||||
record_minimum_warnings(*window, rect, constraint, &mut result.warnings);
|
||||
layout_tree::record_minimum_warnings(
|
||||
*window,
|
||||
rect,
|
||||
constraint,
|
||||
&mut result.warnings,
|
||||
);
|
||||
apply_maximums(*window, &mut rect, constraint, &mut result.warnings);
|
||||
y += row_heights[window_index] + inner;
|
||||
result.geometry.insert(*window, rect);
|
||||
|
||||
@@ -1,926 +1,9 @@
|
||||
//! Panel composition, geometry and allocation share stable group and item identities.
|
||||
mod composition;
|
||||
pub(crate) mod geometry;
|
||||
pub mod layout;
|
||||
pub use geometry::{GeometryIntent, PanelGeometry};
|
||||
mod radius;
|
||||
use crate::PanelStyle;
|
||||
|
||||
pub use composition::*;
|
||||
pub use geometry::{GeometryIntent, PanelGeometry};
|
||||
pub use radius::CornerRadii;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
/// The effects protocol accepts 32 regions; reserve one for the overflow group.
|
||||
pub const MAX_GROUPS: usize = 31;
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn priority() -> u8 {
|
||||
50
|
||||
}
|
||||
fn spacing() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn padding() -> [u16; 2] {
|
||||
[2, 3]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PanelId(pub String);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Edge {
|
||||
#[default]
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Top => "top",
|
||||
Self::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct GroupId(pub String);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ItemId(pub String);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Representation {
|
||||
Wide,
|
||||
Compact,
|
||||
Icon,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OverflowPolicy {
|
||||
#[default]
|
||||
Auto,
|
||||
Never,
|
||||
Always,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum WorkspaceStyle {
|
||||
Numbers,
|
||||
#[default]
|
||||
Dots,
|
||||
Tabs,
|
||||
WindowStacks,
|
||||
AppIcons,
|
||||
}
|
||||
|
||||
impl WorkspaceStyle {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Numbers,
|
||||
Self::Dots,
|
||||
Self::Tabs,
|
||||
Self::WindowStacks,
|
||||
Self::AppIcons,
|
||||
];
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Numbers => "numbers",
|
||||
Self::Dots => "dots",
|
||||
Self::Tabs => "tabs",
|
||||
Self::WindowStacks => "window-stacks",
|
||||
Self::AppIcons => "app-icons",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Numbers => "Numbers",
|
||||
Self::Dots => "Dots",
|
||||
Self::Tabs => "Tabs",
|
||||
Self::WindowStacks => "Window stacks",
|
||||
Self::AppIcons => "App icons",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum ItemKind {
|
||||
Overview,
|
||||
Workspaces {
|
||||
#[serde(default)]
|
||||
style: WorkspaceStyle,
|
||||
},
|
||||
FocusedWindow,
|
||||
Media,
|
||||
Activities,
|
||||
QuickSettings,
|
||||
NightLight,
|
||||
Network,
|
||||
Audio,
|
||||
Recording,
|
||||
Notifications,
|
||||
Battery {
|
||||
#[serde(default = "yes")]
|
||||
percentage: bool,
|
||||
},
|
||||
Clock,
|
||||
DisplayMode,
|
||||
Overflow,
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
/// Smallest useful allocation before a flexible control moves into overflow.
|
||||
pub fn minimum_width(self) -> Option<f32> {
|
||||
match self {
|
||||
Self::FocusedWindow => Some(48.),
|
||||
Self::Workspaces { .. } => Some(24.),
|
||||
Self::Media => Some(112.),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overview => "Overview",
|
||||
Self::Workspaces { .. } => "Workspaces",
|
||||
Self::FocusedWindow => "Focused window",
|
||||
Self::Media => "Media",
|
||||
Self::Activities => "Activities",
|
||||
Self::QuickSettings => "Quick Settings",
|
||||
Self::NightLight => "Night light",
|
||||
Self::Network => "Network",
|
||||
Self::Audio => "Audio",
|
||||
Self::Recording => "Recording",
|
||||
Self::Notifications => "Notifications",
|
||||
Self::Battery { .. } => "Battery",
|
||||
Self::Clock => "Clock",
|
||||
Self::DisplayMode => "Display mode",
|
||||
Self::Overflow => "Overflow",
|
||||
}
|
||||
}
|
||||
pub fn representations(self) -> &'static [Representation] {
|
||||
use Representation::*;
|
||||
match self {
|
||||
Self::Activities | Self::Media | Self::Clock => &[Wide, Compact, Icon],
|
||||
Self::Battery { percentage: true } => &[Wide, Icon],
|
||||
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide],
|
||||
_ => &[Icon],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
#[serde(flatten)]
|
||||
pub kind: ItemKind,
|
||||
// Preserve the existing gap between status controls and the clock/display.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gap_before: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub overflow: OverflowPolicy,
|
||||
#[serde(default = "priority")]
|
||||
pub priority: u8,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub representation: Option<Representation>,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub metadata: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Item {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
let fields = value
|
||||
.as_object()
|
||||
.ok_or_else(|| serde::de::Error::custom("expected a panel item"))?;
|
||||
const OPTIONS: &[&str] = &[
|
||||
"id",
|
||||
"kind",
|
||||
"gap_before",
|
||||
"overflow",
|
||||
"priority",
|
||||
"representation",
|
||||
"visible",
|
||||
"metadata",
|
||||
"style",
|
||||
"percentage",
|
||||
];
|
||||
for key in fields.keys() {
|
||||
if !OPTIONS.contains(&key.as_str()) {
|
||||
return Err(serde::de::Error::unknown_field(key, OPTIONS));
|
||||
}
|
||||
}
|
||||
if fields.contains_key("style")
|
||||
&& fields.get("kind").and_then(serde_json::Value::as_str) != Some("workspaces")
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"style is only supported by Workspaces items",
|
||||
));
|
||||
}
|
||||
if fields.contains_key("percentage")
|
||||
&& fields.get("kind").and_then(serde_json::Value::as_str) != Some("battery")
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"percentage is only supported by Battery items",
|
||||
));
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Fields {
|
||||
id: ItemId,
|
||||
#[serde(flatten)]
|
||||
kind: ItemKind,
|
||||
#[serde(default)]
|
||||
gap_before: Option<f32>,
|
||||
#[serde(default)]
|
||||
overflow: OverflowPolicy,
|
||||
#[serde(default = "priority")]
|
||||
priority: u8,
|
||||
#[serde(default)]
|
||||
representation: Option<Representation>,
|
||||
#[serde(default = "yes")]
|
||||
visible: bool,
|
||||
#[serde(default)]
|
||||
metadata: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
let item: Fields = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||
Ok(Self {
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
gap_before: item.gap_before,
|
||||
overflow: item.overflow,
|
||||
priority: item.priority,
|
||||
representation: item.representation,
|
||||
visible: item.visible,
|
||||
metadata: item.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Start at the configured preference and retain supported smaller forms.
|
||||
pub fn representations(&self) -> Vec<Representation> {
|
||||
let supported = self.kind.representations();
|
||||
let preferred = self.representation.unwrap_or(supported[0]);
|
||||
let alternatives: Vec<_> = supported
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|representation| *representation as u8 >= preferred as u8)
|
||||
.collect();
|
||||
if alternatives.is_empty() {
|
||||
vec![supported[0]]
|
||||
} else {
|
||||
alternatives
|
||||
}
|
||||
}
|
||||
pub fn new(id: &str, kind: ItemKind) -> Self {
|
||||
Self {
|
||||
id: ItemId(id.into()),
|
||||
kind,
|
||||
gap_before: None,
|
||||
representation: None,
|
||||
visible: true,
|
||||
metadata: BTreeMap::new(),
|
||||
overflow: if matches!(
|
||||
kind,
|
||||
ItemKind::Overview | ItemKind::Workspaces { .. } | ItemKind::FocusedWindow
|
||||
) {
|
||||
OverflowPolicy::Never
|
||||
} else {
|
||||
OverflowPolicy::Auto
|
||||
},
|
||||
priority: if matches!(kind, ItemKind::Overview | ItemKind::Workspaces { .. }) {
|
||||
100
|
||||
} else if kind == ItemKind::Activities {
|
||||
20
|
||||
} else {
|
||||
50
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available(&self, availability: Availability) -> bool {
|
||||
if !self.visible {
|
||||
return false;
|
||||
}
|
||||
match self.kind {
|
||||
ItemKind::Activities => availability.activities,
|
||||
ItemKind::Media => availability.media,
|
||||
ItemKind::Network => availability.network,
|
||||
ItemKind::Audio => availability.audio,
|
||||
ItemKind::Notifications => availability.notifications,
|
||||
ItemKind::Battery { .. } => availability.battery,
|
||||
ItemKind::DisplayMode => availability.external_display,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Availability {
|
||||
pub media: bool,
|
||||
pub activities: bool,
|
||||
pub network: bool,
|
||||
pub audio: bool,
|
||||
pub notifications: bool,
|
||||
pub battery: bool,
|
||||
pub external_display: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PanelSurface {
|
||||
None,
|
||||
#[default]
|
||||
Solid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GroupSurface {
|
||||
#[default]
|
||||
None,
|
||||
Inset,
|
||||
Island,
|
||||
}
|
||||
|
||||
impl GroupSurface {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Inset => "inset",
|
||||
Self::Island => "island",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default = "spacing")]
|
||||
pub spacing: f32,
|
||||
#[serde(default = "padding")]
|
||||
pub padding: [u16; 2],
|
||||
#[serde(default = "crate::default_island_padding")]
|
||||
pub island_padding: f32,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
fn new(id: &str, items: Vec<Item>, padding: [u16; 2], island_padding: f32) -> Self {
|
||||
Self {
|
||||
id: GroupId(id.into()),
|
||||
items,
|
||||
spacing: 1.0,
|
||||
padding,
|
||||
island_padding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Zone {
|
||||
pub groups: Vec<Group>,
|
||||
pub spacing: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Panel {
|
||||
pub id: PanelId,
|
||||
#[serde(default)]
|
||||
pub edge: Edge,
|
||||
#[serde(default)]
|
||||
pub geometry: GeometryIntent,
|
||||
#[serde(default)]
|
||||
pub style: PanelStyle,
|
||||
#[serde(default, skip_serializing_if = "default_morph")]
|
||||
pub morph: crate::morph::Profile,
|
||||
#[serde(default)]
|
||||
pub border: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_opacity: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corner_radius: Option<CornerRadii>,
|
||||
#[serde(default)]
|
||||
pub start: Zone,
|
||||
#[serde(default)]
|
||||
pub center: Zone,
|
||||
#[serde(default)]
|
||||
pub end: Zone,
|
||||
}
|
||||
|
||||
impl Default for Zone {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
groups: Vec::new(),
|
||||
spacing: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_morph(profile: &crate::morph::Profile) -> bool {
|
||||
*profile == crate::morph::Profile::default()
|
||||
}
|
||||
|
||||
pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||
if panels.len() != 1 {
|
||||
return Err("configure exactly one panel; multiple panels are not supported yet".into());
|
||||
}
|
||||
let valid_id = |value: &str| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.:@".contains(&byte))
|
||||
&& !value.starts_with('_')
|
||||
};
|
||||
let mut panel_ids = HashSet::new();
|
||||
for panel in panels {
|
||||
if !PanelStyle::AVAILABLE.contains(&panel.style) {
|
||||
return Err(format!(
|
||||
"panel style {} is not available yet",
|
||||
panel.style.key()
|
||||
));
|
||||
}
|
||||
panel.geometry.validate()?;
|
||||
panel.morph.validate()?;
|
||||
if panel.group_count() > MAX_GROUPS {
|
||||
return Err(format!(
|
||||
"a panel supports at most {MAX_GROUPS} groups, reserving one region for overflow"
|
||||
));
|
||||
}
|
||||
if let Some(opacity) = panel.background_opacity
|
||||
&& (!opacity.is_finite() || !(0.0..=1.0).contains(&opacity))
|
||||
{
|
||||
return Err("panel background opacity must be between 0 and 1".into());
|
||||
}
|
||||
if let Some(radius) = panel.corner_radius {
|
||||
radius.validate()?;
|
||||
}
|
||||
if !valid_id(&panel.id.0) || !panel_ids.insert(&panel.id.0) {
|
||||
return Err(format!("invalid or duplicate panel ID {:?}", panel.id.0));
|
||||
}
|
||||
let mut group_ids = HashSet::new();
|
||||
let mut item_ids = HashSet::new();
|
||||
for zone in [&panel.start, &panel.center, &panel.end] {
|
||||
if !zone.spacing.is_finite() || !(0.0..=64.0).contains(&zone.spacing) {
|
||||
return Err("panel zone spacing must be between 0 and 64".into());
|
||||
}
|
||||
for group in &zone.groups {
|
||||
if !valid_id(&group.id.0) || !group_ids.insert(&group.id.0) {
|
||||
return Err(format!("invalid or duplicate group ID {:?}", group.id.0));
|
||||
}
|
||||
if !group.spacing.is_finite()
|
||||
|| !(0.0..=64.0).contains(&group.spacing)
|
||||
|| !group.island_padding.is_finite()
|
||||
|| !(0.0..=32.0).contains(&group.island_padding)
|
||||
|| group.padding.iter().any(|value| *value > 32)
|
||||
{
|
||||
return Err("invalid panel group spacing or padding".into());
|
||||
}
|
||||
for item in &group.items {
|
||||
if !valid_id(&item.id.0) || !item_ids.insert(&item.id.0) {
|
||||
return Err(format!("invalid or duplicate item ID {:?}", item.id.0));
|
||||
}
|
||||
if item.priority > 100
|
||||
|| item
|
||||
.gap_before
|
||||
.is_some_and(|gap| !gap.is_finite() || !(0.0..=64.0).contains(&gap))
|
||||
{
|
||||
return Err("panel item priority must be 0..100 and gaps 0..64".into());
|
||||
}
|
||||
if item.kind == ItemKind::Overflow {
|
||||
return Err(
|
||||
"overflow is supplied by the panel; it cannot be configured as an item"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Default for Panel {
|
||||
fn default() -> Self {
|
||||
Self::from_preset(PanelStyle::Flat)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn preset(&self) -> Option<PanelStyle> {
|
||||
Some(self.style)
|
||||
}
|
||||
|
||||
pub fn apply_preset(&mut self, style: PanelStyle) {
|
||||
self.style = style;
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> PanelSurface {
|
||||
if matches!(self.style, PanelStyle::Flat | PanelStyle::Frame) {
|
||||
PanelSurface::Solid
|
||||
} else {
|
||||
PanelSurface::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_surface(&self) -> GroupSurface {
|
||||
if self.surface() == PanelSurface::Solid {
|
||||
GroupSurface::None
|
||||
} else {
|
||||
GroupSurface::Island
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zone_spacing(&self, zone: &Zone) -> f32 {
|
||||
if self.style.connected() {
|
||||
0.
|
||||
} else {
|
||||
zone.spacing
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shape_parameters(&self) -> crate::scene::Parameters {
|
||||
crate::scene::Parameters {
|
||||
style: self.style,
|
||||
attached: self.style == PanelStyle::Morph && self.morph.attached,
|
||||
fillet: if self.style == PanelStyle::Morph {
|
||||
12.
|
||||
} else {
|
||||
crate::scene::Parameters::default().fillet
|
||||
},
|
||||
edge: self.edge,
|
||||
height: self.resolved_geometry().height.into(),
|
||||
radius: self.corner_radius.map(|r| crate::shape::AnchoredRadii(r.0)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_geometry(&self) -> PanelGeometry {
|
||||
let mut geometry = self.geometry.resolve(self.style);
|
||||
if self.style == PanelStyle::Morph && self.morph.attached {
|
||||
geometry.edge_margin = 0;
|
||||
}
|
||||
geometry
|
||||
}
|
||||
|
||||
pub fn exclusive_zone(&self, hidden: bool) -> i32 {
|
||||
if hidden {
|
||||
0
|
||||
} else {
|
||||
let geometry = self.resolved_geometry();
|
||||
(geometry.height.round() as i32).saturating_add(geometry.window_clearance)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_count(&self) -> usize {
|
||||
[&self.start, &self.center, &self.end]
|
||||
.into_iter()
|
||||
.map(|zone| zone.groups.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Appearance changes do not replace item ownership or measured allocation.
|
||||
pub fn same_composition(&self, other: &Self) -> bool {
|
||||
let same_zone = |left: &Zone, right: &Zone| {
|
||||
left.spacing == right.spacing
|
||||
&& left.groups.len() == right.groups.len()
|
||||
&& left.groups.iter().zip(&right.groups).all(|(left, right)| {
|
||||
left.id == right.id
|
||||
&& left.items == right.items
|
||||
&& left.spacing == right.spacing
|
||||
&& left.padding == right.padding
|
||||
&& left.island_padding == right.island_padding
|
||||
&& (self.group_surface() == GroupSurface::Island)
|
||||
== (other.group_surface() == GroupSurface::Island)
|
||||
})
|
||||
};
|
||||
self.id == other.id
|
||||
&& self.edge == other.edge
|
||||
&& self.style == other.style
|
||||
&& self.morph == other.morph
|
||||
&& same_zone(&self.start, &other.start)
|
||||
&& same_zone(&self.center, &other.center)
|
||||
&& same_zone(&self.end, &other.end)
|
||||
}
|
||||
|
||||
pub fn resolved_radius(&self, shell_radius: f32) -> CornerRadii {
|
||||
self.corner_radius
|
||||
.unwrap_or_else(|| CornerRadii::uniform(shell_radius))
|
||||
}
|
||||
|
||||
/// The overflow trigger is generated, never persisted as a configured item.
|
||||
pub fn overflow_group(&self) -> Group {
|
||||
Group {
|
||||
id: GroupId("_overflow".into()),
|
||||
items: vec![Item {
|
||||
id: ItemId("_overflow".into()),
|
||||
kind: ItemKind::Overflow,
|
||||
gap_before: None,
|
||||
representation: None,
|
||||
visible: true,
|
||||
metadata: BTreeMap::new(),
|
||||
overflow: OverflowPolicy::Never,
|
||||
priority: 100,
|
||||
}],
|
||||
spacing: 0.0,
|
||||
padding: [2, 3],
|
||||
island_padding: self
|
||||
.end
|
||||
.groups
|
||||
.first()
|
||||
.map_or(crate::default_island_padding(), |group| {
|
||||
group.island_padding
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item(&self, id: &ItemId) -> Option<&Item> {
|
||||
[&self.start, &self.center, &self.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.find(|item| &item.id == id)
|
||||
}
|
||||
|
||||
/// Build a stable initial composition; presets are an editor convenience.
|
||||
pub fn from_preset(preset: PanelStyle) -> Self {
|
||||
use ItemKind::*;
|
||||
|
||||
let item = Item::new;
|
||||
let padding = crate::default_island_padding();
|
||||
let overview = item("overview", Overview);
|
||||
let workspaces = item(
|
||||
"workspaces",
|
||||
Workspaces {
|
||||
style: WorkspaceStyle::default(),
|
||||
},
|
||||
);
|
||||
let title = item("focused-window", FocusedWindow);
|
||||
let controls = vec![
|
||||
item("media", Media),
|
||||
item("quick-settings", QuickSettings),
|
||||
item("night-light", NightLight),
|
||||
item("network", Network),
|
||||
item("audio", Audio),
|
||||
item("recording", Recording),
|
||||
item("notifications", Notifications),
|
||||
item("battery", Battery { percentage: true }),
|
||||
];
|
||||
let clock = item("clock", Clock);
|
||||
let display = item("display-mode", DisplayMode);
|
||||
|
||||
let (start, center, end) = match preset {
|
||||
PanelStyle::Flat => (
|
||||
vec![
|
||||
Group::new("overview", vec![overview], [0, 0], padding),
|
||||
Group::new("workspaces", vec![workspaces], [2, 3], padding),
|
||||
],
|
||||
Group::new("title", vec![title], [0, 0], padding),
|
||||
vec![
|
||||
Group::new("status", controls, [2, 3], padding),
|
||||
Group::new("time", vec![clock], [2, 3], padding),
|
||||
Group::new("display", vec![display], [0, 0], padding),
|
||||
],
|
||||
),
|
||||
_ => {
|
||||
let mut controls = controls;
|
||||
controls.extend([clock, display]);
|
||||
(
|
||||
vec![Group::new(
|
||||
"navigation",
|
||||
vec![overview, workspaces],
|
||||
[2, 3],
|
||||
padding,
|
||||
)],
|
||||
Group::new("title", vec![title], [0, 0], padding),
|
||||
vec![Group::new("status", controls, [2, 3], padding)],
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
id: PanelId("main".into()),
|
||||
edge: Edge::Top,
|
||||
geometry: GeometryIntent::default(),
|
||||
style: preset,
|
||||
morph: Default::default(),
|
||||
border: false,
|
||||
background_opacity: Option::None,
|
||||
corner_radius: Option::None,
|
||||
start: Zone {
|
||||
groups: start,
|
||||
spacing: if preset == PanelStyle::Floating {
|
||||
8.0
|
||||
} else {
|
||||
3.0
|
||||
},
|
||||
},
|
||||
center: Zone {
|
||||
groups: vec![center],
|
||||
spacing: 8.0,
|
||||
},
|
||||
end: Zone {
|
||||
groups: end,
|
||||
spacing: 8.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fullscreen_releases_the_entire_reservation_without_discarding_pins() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
panel.geometry.height = Some(36.);
|
||||
panel.geometry.window_clearance = Some(4);
|
||||
assert_eq!(panel.exclusive_zone(false), 40);
|
||||
assert_eq!(panel.exclusive_zone(true), 0);
|
||||
assert_eq!(panel.geometry.window_clearance, Some(4));
|
||||
}
|
||||
|
||||
fn items(panel: &Panel) -> Vec<&Item> {
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_capacity_reserves_overflow_even_for_empty_groups() {
|
||||
for background in [PanelStyle::Flat, PanelStyle::Floating] {
|
||||
let mut panel = Panel::from_preset(background);
|
||||
panel.start.groups.clear();
|
||||
panel.center.groups.clear();
|
||||
panel.end.groups = (0..MAX_GROUPS)
|
||||
.map(|i| Group::new(&format!("group-{i}"), Vec::new(), [2, 3], 4.))
|
||||
.collect();
|
||||
assert_eq!(panel.group_count(), MAX_GROUPS);
|
||||
validate(std::slice::from_ref(&panel)).unwrap();
|
||||
panel
|
||||
.start
|
||||
.groups
|
||||
.push(Group::new("one-too-many", Vec::new(), [2, 3], 4.));
|
||||
assert!(validate(&[panel]).unwrap_err().contains("31 groups"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_preserve_order_and_ids_across_background_changes() {
|
||||
let continuous = Panel::default();
|
||||
let islands = Panel::from_preset(PanelStyle::Floating);
|
||||
let expected = [
|
||||
"overview",
|
||||
"workspaces",
|
||||
"focused-window",
|
||||
"media",
|
||||
"quick-settings",
|
||||
"night-light",
|
||||
"network",
|
||||
"audio",
|
||||
"recording",
|
||||
"notifications",
|
||||
"battery",
|
||||
"clock",
|
||||
"display-mode",
|
||||
];
|
||||
for panel in [&continuous, &islands] {
|
||||
let ids: Vec<_> = items(panel).iter().map(|item| item.id.0.as_str()).collect();
|
||||
assert_eq!(ids, expected);
|
||||
assert_eq!(Panel::from_preset(panel.preset().unwrap()), *panel);
|
||||
}
|
||||
assert_eq!(continuous.start.groups.len(), 2);
|
||||
assert_eq!(continuous.end.groups.len(), 3);
|
||||
assert_eq!(islands.start.groups.len(), 1);
|
||||
assert_eq!(islands.end.groups.len(), 1);
|
||||
assert!(!islands.border && !continuous.border);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_filters_without_removing_or_reidentifying_instances() {
|
||||
let panel = Panel::default();
|
||||
let all = items(&panel);
|
||||
let visible = |availability| {
|
||||
all.iter()
|
||||
.filter(|item| item.available(availability))
|
||||
.map(|item| item.id.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
visible(Availability::default()),
|
||||
[
|
||||
"overview",
|
||||
"workspaces",
|
||||
"focused-window",
|
||||
"quick-settings",
|
||||
"night-light",
|
||||
"recording",
|
||||
"clock"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
visible(Availability {
|
||||
activities: true,
|
||||
media: true,
|
||||
network: true,
|
||||
audio: true,
|
||||
notifications: true,
|
||||
battery: true,
|
||||
external_display: true
|
||||
}),
|
||||
all.iter()
|
||||
.map(|item| item.id.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(items(&panel).len(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_map_to_instances_and_group_presentation() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
panel.center.groups[0].items[0].visible = false;
|
||||
let battery = panel.end.groups[0]
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.id.0 == "battery")
|
||||
.unwrap();
|
||||
battery.kind = ItemKind::Battery { percentage: false };
|
||||
for group in [&mut panel.start, &mut panel.center, &mut panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &mut zone.groups)
|
||||
{
|
||||
group.island_padding = 13.;
|
||||
}
|
||||
assert_eq!(
|
||||
panel.center.groups[0].items[0].kind,
|
||||
ItemKind::FocusedWindow
|
||||
);
|
||||
assert!(!panel.center.groups[0].items[0].visible);
|
||||
let all = items(&panel);
|
||||
assert_eq!(
|
||||
all.iter().find(|item| item.id.0 == "battery").unwrap().kind,
|
||||
ItemKind::Battery { percentage: false }
|
||||
);
|
||||
for zone in [&panel.start, &panel.center, &panel.end] {
|
||||
for group in &zone.groups {
|
||||
assert_eq!(group.island_padding, 13.0);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|item| item.id.0 == "clock")
|
||||
.unwrap()
|
||||
.gap_before,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clock_instances_have_distinct_identity() {
|
||||
let first = Item::new("main-clock", ItemKind::Clock);
|
||||
let second = Item::new("other-clock", ItemKind::Clock);
|
||||
assert_eq!(first.kind, second.kind);
|
||||
assert_ne!(first.id, second.id);
|
||||
}
|
||||
#[test]
|
||||
fn removed_item_fields_are_rejected() {
|
||||
for value in [
|
||||
serde_json::json!({"id":"title", "kind":"focused-window", "enabled":false}),
|
||||
serde_json::json!({"id":"spaces", "kind":"workspaces", "workspace_style":"numbers"}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<Item>(value).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_changes_only_replace_composition_when_item_geometry_changes() {
|
||||
let panel = Panel::default();
|
||||
let mut changed = panel.clone();
|
||||
changed.style = PanelStyle::Flat;
|
||||
assert!(panel.same_composition(&changed));
|
||||
changed.style = PanelStyle::Floating;
|
||||
assert!(!panel.same_composition(&changed));
|
||||
changed.style = PanelStyle::Flat;
|
||||
assert!(panel.same_composition(&changed));
|
||||
changed.end.groups[0].padding[1] += 1;
|
||||
assert!(!panel.same_composition(&changed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
use super::{CornerRadii, GeometryIntent, PanelGeometry};
|
||||
use crate::PanelStyle;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
/// The effects protocol accepts 32 regions; reserve one for the overflow group.
|
||||
pub const MAX_GROUPS: usize = 31;
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn priority() -> u8 {
|
||||
50
|
||||
}
|
||||
fn spacing() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn padding() -> [u16; 2] {
|
||||
[2, 3]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PanelId(pub String);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Edge {
|
||||
#[default]
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Top => "top",
|
||||
Self::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct GroupId(pub String);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ItemId(pub String);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Representation {
|
||||
Wide,
|
||||
Compact,
|
||||
Icon,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OverflowPolicy {
|
||||
#[default]
|
||||
Auto,
|
||||
Never,
|
||||
Always,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum WorkspaceStyle {
|
||||
Numbers,
|
||||
#[default]
|
||||
Dots,
|
||||
Tabs,
|
||||
WindowStacks,
|
||||
AppIcons,
|
||||
}
|
||||
|
||||
impl WorkspaceStyle {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Numbers,
|
||||
Self::Dots,
|
||||
Self::Tabs,
|
||||
Self::WindowStacks,
|
||||
Self::AppIcons,
|
||||
];
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Numbers => "numbers",
|
||||
Self::Dots => "dots",
|
||||
Self::Tabs => "tabs",
|
||||
Self::WindowStacks => "window-stacks",
|
||||
Self::AppIcons => "app-icons",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Numbers => "Numbers",
|
||||
Self::Dots => "Dots",
|
||||
Self::Tabs => "Tabs",
|
||||
Self::WindowStacks => "Window stacks",
|
||||
Self::AppIcons => "App icons",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum ItemKind {
|
||||
Overview,
|
||||
Workspaces {
|
||||
#[serde(default)]
|
||||
style: WorkspaceStyle,
|
||||
},
|
||||
FocusedWindow,
|
||||
Media,
|
||||
Activities,
|
||||
QuickSettings,
|
||||
NightLight,
|
||||
Network,
|
||||
Audio,
|
||||
Recording,
|
||||
Notifications,
|
||||
Battery {
|
||||
#[serde(default = "yes")]
|
||||
percentage: bool,
|
||||
},
|
||||
Clock,
|
||||
DisplayMode,
|
||||
Overflow,
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
/// Smallest useful allocation before a flexible control moves into overflow.
|
||||
pub fn minimum_width(self) -> Option<f32> {
|
||||
match self {
|
||||
Self::FocusedWindow => Some(48.),
|
||||
Self::Workspaces { .. } => Some(24.),
|
||||
Self::Media => Some(112.),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overview => "Overview",
|
||||
Self::Workspaces { .. } => "Workspaces",
|
||||
Self::FocusedWindow => "Focused window",
|
||||
Self::Media => "Media",
|
||||
Self::Activities => "Activities",
|
||||
Self::QuickSettings => "Quick Settings",
|
||||
Self::NightLight => "Night light",
|
||||
Self::Network => "Network",
|
||||
Self::Audio => "Audio",
|
||||
Self::Recording => "Recording",
|
||||
Self::Notifications => "Notifications",
|
||||
Self::Battery { .. } => "Battery",
|
||||
Self::Clock => "Clock",
|
||||
Self::DisplayMode => "Display mode",
|
||||
Self::Overflow => "Overflow",
|
||||
}
|
||||
}
|
||||
pub fn representations(self) -> &'static [Representation] {
|
||||
use Representation::*;
|
||||
match self {
|
||||
Self::Activities | Self::Media | Self::Clock => &[Wide, Compact, Icon],
|
||||
Self::Battery { percentage: true } => &[Wide, Icon],
|
||||
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide],
|
||||
_ => &[Icon],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
#[serde(flatten)]
|
||||
pub kind: ItemKind,
|
||||
// Preserve the existing gap between status controls and the clock/display.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gap_before: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub overflow: OverflowPolicy,
|
||||
#[serde(default = "priority")]
|
||||
pub priority: u8,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub representation: Option<Representation>,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub metadata: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Item {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
let fields = value
|
||||
.as_object()
|
||||
.ok_or_else(|| serde::de::Error::custom("expected a panel item"))?;
|
||||
const OPTIONS: &[&str] = &[
|
||||
"id",
|
||||
"kind",
|
||||
"gap_before",
|
||||
"overflow",
|
||||
"priority",
|
||||
"representation",
|
||||
"visible",
|
||||
"metadata",
|
||||
"style",
|
||||
"percentage",
|
||||
];
|
||||
for key in fields.keys() {
|
||||
if !OPTIONS.contains(&key.as_str()) {
|
||||
return Err(serde::de::Error::unknown_field(key, OPTIONS));
|
||||
}
|
||||
}
|
||||
if fields.contains_key("style")
|
||||
&& fields.get("kind").and_then(serde_json::Value::as_str) != Some("workspaces")
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"style is only supported by Workspaces items",
|
||||
));
|
||||
}
|
||||
if fields.contains_key("percentage")
|
||||
&& fields.get("kind").and_then(serde_json::Value::as_str) != Some("battery")
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"percentage is only supported by Battery items",
|
||||
));
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Fields {
|
||||
id: ItemId,
|
||||
#[serde(flatten)]
|
||||
kind: ItemKind,
|
||||
#[serde(default)]
|
||||
gap_before: Option<f32>,
|
||||
#[serde(default)]
|
||||
overflow: OverflowPolicy,
|
||||
#[serde(default = "priority")]
|
||||
priority: u8,
|
||||
#[serde(default)]
|
||||
representation: Option<Representation>,
|
||||
#[serde(default = "yes")]
|
||||
visible: bool,
|
||||
#[serde(default)]
|
||||
metadata: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
let item: Fields = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||
Ok(Self {
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
gap_before: item.gap_before,
|
||||
overflow: item.overflow,
|
||||
priority: item.priority,
|
||||
representation: item.representation,
|
||||
visible: item.visible,
|
||||
metadata: item.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Start at the configured preference and retain supported smaller forms.
|
||||
pub fn representations(&self) -> Vec<Representation> {
|
||||
let supported = self.kind.representations();
|
||||
let preferred = self.representation.unwrap_or(supported[0]);
|
||||
let alternatives: Vec<_> = supported
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|representation| *representation as u8 >= preferred as u8)
|
||||
.collect();
|
||||
if alternatives.is_empty() {
|
||||
vec![supported[0]]
|
||||
} else {
|
||||
alternatives
|
||||
}
|
||||
}
|
||||
pub fn new(id: &str, kind: ItemKind) -> Self {
|
||||
Self {
|
||||
id: ItemId(id.into()),
|
||||
kind,
|
||||
gap_before: None,
|
||||
representation: None,
|
||||
visible: true,
|
||||
metadata: BTreeMap::new(),
|
||||
overflow: if matches!(
|
||||
kind,
|
||||
ItemKind::Overview | ItemKind::Workspaces { .. } | ItemKind::FocusedWindow
|
||||
) {
|
||||
OverflowPolicy::Never
|
||||
} else {
|
||||
OverflowPolicy::Auto
|
||||
},
|
||||
priority: if matches!(kind, ItemKind::Overview | ItemKind::Workspaces { .. }) {
|
||||
100
|
||||
} else if kind == ItemKind::Activities {
|
||||
20
|
||||
} else {
|
||||
50
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available(&self, availability: Availability) -> bool {
|
||||
if !self.visible {
|
||||
return false;
|
||||
}
|
||||
match self.kind {
|
||||
ItemKind::Activities => availability.activities,
|
||||
ItemKind::Media => availability.media,
|
||||
ItemKind::Network => availability.network,
|
||||
ItemKind::Audio => availability.audio,
|
||||
ItemKind::Notifications => availability.notifications,
|
||||
ItemKind::Battery { .. } => availability.battery,
|
||||
ItemKind::DisplayMode => availability.external_display,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Availability {
|
||||
pub media: bool,
|
||||
pub activities: bool,
|
||||
pub network: bool,
|
||||
pub audio: bool,
|
||||
pub notifications: bool,
|
||||
pub battery: bool,
|
||||
pub external_display: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PanelSurface {
|
||||
None,
|
||||
#[default]
|
||||
Solid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GroupSurface {
|
||||
#[default]
|
||||
None,
|
||||
Inset,
|
||||
Island,
|
||||
}
|
||||
|
||||
impl GroupSurface {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Inset => "inset",
|
||||
Self::Island => "island",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default = "spacing")]
|
||||
pub spacing: f32,
|
||||
#[serde(default = "padding")]
|
||||
pub padding: [u16; 2],
|
||||
#[serde(default = "crate::default_island_padding")]
|
||||
pub island_padding: f32,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub(super) fn new(id: &str, items: Vec<Item>, padding: [u16; 2], island_padding: f32) -> Self {
|
||||
Self {
|
||||
id: GroupId(id.into()),
|
||||
items,
|
||||
spacing: 1.0,
|
||||
padding,
|
||||
island_padding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Zone {
|
||||
pub groups: Vec<Group>,
|
||||
pub spacing: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Panel {
|
||||
pub id: PanelId,
|
||||
#[serde(default)]
|
||||
pub edge: Edge,
|
||||
#[serde(default)]
|
||||
pub geometry: GeometryIntent,
|
||||
#[serde(default)]
|
||||
pub style: PanelStyle,
|
||||
#[serde(default, skip_serializing_if = "default_morph")]
|
||||
pub morph: crate::morph::Profile,
|
||||
#[serde(default)]
|
||||
pub border: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_opacity: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corner_radius: Option<CornerRadii>,
|
||||
#[serde(default)]
|
||||
pub start: Zone,
|
||||
#[serde(default)]
|
||||
pub center: Zone,
|
||||
#[serde(default)]
|
||||
pub end: Zone,
|
||||
}
|
||||
|
||||
impl Default for Zone {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
groups: Vec::new(),
|
||||
spacing: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_morph(profile: &crate::morph::Profile) -> bool {
|
||||
*profile == crate::morph::Profile::default()
|
||||
}
|
||||
|
||||
pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||
if panels.len() != 1 {
|
||||
return Err("configure exactly one panel; multiple panels are not supported yet".into());
|
||||
}
|
||||
let valid_id = |value: &str| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.:@".contains(&byte))
|
||||
&& !value.starts_with('_')
|
||||
};
|
||||
let mut panel_ids = HashSet::new();
|
||||
for panel in panels {
|
||||
if !PanelStyle::AVAILABLE.contains(&panel.style) {
|
||||
return Err(format!(
|
||||
"panel style {} is not available yet",
|
||||
panel.style.key()
|
||||
));
|
||||
}
|
||||
panel.geometry.validate()?;
|
||||
panel.morph.validate()?;
|
||||
if panel.group_count() > MAX_GROUPS {
|
||||
return Err(format!(
|
||||
"a panel supports at most {MAX_GROUPS} groups, reserving one region for overflow"
|
||||
));
|
||||
}
|
||||
if let Some(opacity) = panel.background_opacity
|
||||
&& (!opacity.is_finite() || !(0.0..=1.0).contains(&opacity))
|
||||
{
|
||||
return Err("panel background opacity must be between 0 and 1".into());
|
||||
}
|
||||
if let Some(radius) = panel.corner_radius {
|
||||
radius.validate()?;
|
||||
}
|
||||
if !valid_id(&panel.id.0) || !panel_ids.insert(&panel.id.0) {
|
||||
return Err(format!("invalid or duplicate panel ID {:?}", panel.id.0));
|
||||
}
|
||||
let mut group_ids = HashSet::new();
|
||||
let mut item_ids = HashSet::new();
|
||||
for zone in [&panel.start, &panel.center, &panel.end] {
|
||||
if !zone.spacing.is_finite() || !(0.0..=64.0).contains(&zone.spacing) {
|
||||
return Err("panel zone spacing must be between 0 and 64".into());
|
||||
}
|
||||
for group in &zone.groups {
|
||||
if !valid_id(&group.id.0) || !group_ids.insert(&group.id.0) {
|
||||
return Err(format!("invalid or duplicate group ID {:?}", group.id.0));
|
||||
}
|
||||
if !group.spacing.is_finite()
|
||||
|| !(0.0..=64.0).contains(&group.spacing)
|
||||
|| !group.island_padding.is_finite()
|
||||
|| !(0.0..=32.0).contains(&group.island_padding)
|
||||
|| group.padding.iter().any(|value| *value > 32)
|
||||
{
|
||||
return Err("invalid panel group spacing or padding".into());
|
||||
}
|
||||
for item in &group.items {
|
||||
if !valid_id(&item.id.0) || !item_ids.insert(&item.id.0) {
|
||||
return Err(format!("invalid or duplicate item ID {:?}", item.id.0));
|
||||
}
|
||||
if item.priority > 100
|
||||
|| item
|
||||
.gap_before
|
||||
.is_some_and(|gap| !gap.is_finite() || !(0.0..=64.0).contains(&gap))
|
||||
{
|
||||
return Err("panel item priority must be 0..100 and gaps 0..64".into());
|
||||
}
|
||||
if item.kind == ItemKind::Overflow {
|
||||
return Err(
|
||||
"overflow is supplied by the panel; it cannot be configured as an item"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Default for Panel {
|
||||
fn default() -> Self {
|
||||
Self::from_preset(PanelStyle::Flat)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn preset(&self) -> Option<PanelStyle> {
|
||||
Some(self.style)
|
||||
}
|
||||
|
||||
pub fn apply_preset(&mut self, style: PanelStyle) {
|
||||
self.style = style;
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> PanelSurface {
|
||||
if matches!(self.style, PanelStyle::Flat | PanelStyle::Frame) {
|
||||
PanelSurface::Solid
|
||||
} else {
|
||||
PanelSurface::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_surface(&self) -> GroupSurface {
|
||||
if self.surface() == PanelSurface::Solid {
|
||||
GroupSurface::None
|
||||
} else {
|
||||
GroupSurface::Island
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zone_spacing(&self, zone: &Zone) -> f32 {
|
||||
if self.style.connected() {
|
||||
0.
|
||||
} else {
|
||||
zone.spacing
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shape_parameters(&self) -> crate::scene::Parameters {
|
||||
crate::scene::Parameters {
|
||||
style: self.style,
|
||||
attached: self.style == PanelStyle::Morph && self.morph.attached,
|
||||
fillet: if self.style == PanelStyle::Morph {
|
||||
12.
|
||||
} else {
|
||||
crate::scene::Parameters::default().fillet
|
||||
},
|
||||
edge: self.edge,
|
||||
height: self.resolved_geometry().height.into(),
|
||||
radius: self.corner_radius.map(|r| crate::shape::AnchoredRadii(r.0)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_geometry(&self) -> PanelGeometry {
|
||||
let mut geometry = self.geometry.resolve(self.style);
|
||||
if self.style == PanelStyle::Morph && self.morph.attached {
|
||||
geometry.edge_margin = 0;
|
||||
}
|
||||
geometry
|
||||
}
|
||||
|
||||
pub fn exclusive_zone(&self, hidden: bool) -> i32 {
|
||||
if hidden {
|
||||
0
|
||||
} else {
|
||||
let geometry = self.resolved_geometry();
|
||||
(geometry.height.round() as i32).saturating_add(geometry.window_clearance)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_count(&self) -> usize {
|
||||
[&self.start, &self.center, &self.end]
|
||||
.into_iter()
|
||||
.map(|zone| zone.groups.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Appearance changes do not replace item ownership or measured allocation.
|
||||
pub fn same_composition(&self, other: &Self) -> bool {
|
||||
let same_zone = |left: &Zone, right: &Zone| {
|
||||
left.spacing == right.spacing
|
||||
&& left.groups.len() == right.groups.len()
|
||||
&& left.groups.iter().zip(&right.groups).all(|(left, right)| {
|
||||
left.id == right.id
|
||||
&& left.items == right.items
|
||||
&& left.spacing == right.spacing
|
||||
&& left.padding == right.padding
|
||||
&& left.island_padding == right.island_padding
|
||||
&& (self.group_surface() == GroupSurface::Island)
|
||||
== (other.group_surface() == GroupSurface::Island)
|
||||
})
|
||||
};
|
||||
self.id == other.id
|
||||
&& self.edge == other.edge
|
||||
&& self.style == other.style
|
||||
&& self.morph == other.morph
|
||||
&& same_zone(&self.start, &other.start)
|
||||
&& same_zone(&self.center, &other.center)
|
||||
&& same_zone(&self.end, &other.end)
|
||||
}
|
||||
|
||||
pub fn resolved_radius(&self, shell_radius: f32) -> CornerRadii {
|
||||
self.corner_radius
|
||||
.unwrap_or_else(|| CornerRadii::uniform(shell_radius))
|
||||
}
|
||||
|
||||
/// The overflow trigger is generated, never persisted as a configured item.
|
||||
pub fn overflow_group(&self) -> Group {
|
||||
Group {
|
||||
id: GroupId("_overflow".into()),
|
||||
items: vec![Item {
|
||||
id: ItemId("_overflow".into()),
|
||||
kind: ItemKind::Overflow,
|
||||
gap_before: None,
|
||||
representation: None,
|
||||
visible: true,
|
||||
metadata: BTreeMap::new(),
|
||||
overflow: OverflowPolicy::Never,
|
||||
priority: 100,
|
||||
}],
|
||||
spacing: 0.0,
|
||||
padding: [2, 3],
|
||||
island_padding: self
|
||||
.end
|
||||
.groups
|
||||
.first()
|
||||
.map_or(crate::default_island_padding(), |group| {
|
||||
group.island_padding
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item(&self, id: &ItemId) -> Option<&Item> {
|
||||
[&self.start, &self.center, &self.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.find(|item| &item.id == id)
|
||||
}
|
||||
|
||||
/// Build a stable initial composition; presets are an editor convenience.
|
||||
pub fn from_preset(preset: PanelStyle) -> Self {
|
||||
use ItemKind::*;
|
||||
|
||||
let item = Item::new;
|
||||
let padding = crate::default_island_padding();
|
||||
let overview = item("overview", Overview);
|
||||
let workspaces = item(
|
||||
"workspaces",
|
||||
Workspaces {
|
||||
style: WorkspaceStyle::default(),
|
||||
},
|
||||
);
|
||||
let title = item("focused-window", FocusedWindow);
|
||||
let controls = vec![
|
||||
item("media", Media),
|
||||
item("quick-settings", QuickSettings),
|
||||
item("night-light", NightLight),
|
||||
item("network", Network),
|
||||
item("audio", Audio),
|
||||
item("recording", Recording),
|
||||
item("notifications", Notifications),
|
||||
item("battery", Battery { percentage: true }),
|
||||
];
|
||||
let clock = item("clock", Clock);
|
||||
let display = item("display-mode", DisplayMode);
|
||||
|
||||
let (start, center, end) = match preset {
|
||||
PanelStyle::Flat => (
|
||||
vec![
|
||||
Group::new("overview", vec![overview], [0, 0], padding),
|
||||
Group::new("workspaces", vec![workspaces], [2, 3], padding),
|
||||
],
|
||||
Group::new("title", vec![title], [0, 0], padding),
|
||||
vec![
|
||||
Group::new("status", controls, [2, 3], padding),
|
||||
Group::new("time", vec![clock], [2, 3], padding),
|
||||
Group::new("display", vec![display], [0, 0], padding),
|
||||
],
|
||||
),
|
||||
_ => {
|
||||
let mut controls = controls;
|
||||
controls.extend([clock, display]);
|
||||
(
|
||||
vec![Group::new(
|
||||
"navigation",
|
||||
vec![overview, workspaces],
|
||||
[2, 3],
|
||||
padding,
|
||||
)],
|
||||
Group::new("title", vec![title], [0, 0], padding),
|
||||
vec![Group::new("status", controls, [2, 3], padding)],
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
id: PanelId("main".into()),
|
||||
edge: Edge::Top,
|
||||
geometry: GeometryIntent::default(),
|
||||
style: preset,
|
||||
morph: Default::default(),
|
||||
border: false,
|
||||
background_opacity: Option::None,
|
||||
corner_radius: Option::None,
|
||||
start: Zone {
|
||||
groups: start,
|
||||
spacing: if preset == PanelStyle::Floating {
|
||||
8.0
|
||||
} else {
|
||||
3.0
|
||||
},
|
||||
},
|
||||
center: Zone {
|
||||
groups: vec![center],
|
||||
spacing: 8.0,
|
||||
},
|
||||
end: Zone {
|
||||
groups: end,
|
||||
spacing: 8.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fullscreen_releases_the_entire_reservation_without_discarding_pins() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
panel.geometry.height = Some(36.);
|
||||
panel.geometry.window_clearance = Some(4);
|
||||
assert_eq!(panel.exclusive_zone(false), 40);
|
||||
assert_eq!(panel.exclusive_zone(true), 0);
|
||||
assert_eq!(panel.geometry.window_clearance, Some(4));
|
||||
}
|
||||
|
||||
fn items(panel: &Panel) -> Vec<&Item> {
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_capacity_reserves_overflow_even_for_empty_groups() {
|
||||
for background in [PanelStyle::Flat, PanelStyle::Floating] {
|
||||
let mut panel = Panel::from_preset(background);
|
||||
panel.start.groups.clear();
|
||||
panel.center.groups.clear();
|
||||
panel.end.groups = (0..MAX_GROUPS)
|
||||
.map(|i| Group::new(&format!("group-{i}"), Vec::new(), [2, 3], 4.))
|
||||
.collect();
|
||||
assert_eq!(panel.group_count(), MAX_GROUPS);
|
||||
validate(std::slice::from_ref(&panel)).unwrap();
|
||||
panel
|
||||
.start
|
||||
.groups
|
||||
.push(Group::new("one-too-many", Vec::new(), [2, 3], 4.));
|
||||
assert!(validate(&[panel]).unwrap_err().contains("31 groups"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_preserve_order_and_ids_across_background_changes() {
|
||||
let continuous = Panel::default();
|
||||
let islands = Panel::from_preset(PanelStyle::Floating);
|
||||
let expected = [
|
||||
"overview",
|
||||
"workspaces",
|
||||
"focused-window",
|
||||
"media",
|
||||
"quick-settings",
|
||||
"night-light",
|
||||
"network",
|
||||
"audio",
|
||||
"recording",
|
||||
"notifications",
|
||||
"battery",
|
||||
"clock",
|
||||
"display-mode",
|
||||
];
|
||||
for panel in [&continuous, &islands] {
|
||||
let ids: Vec<_> = items(panel).iter().map(|item| item.id.0.as_str()).collect();
|
||||
assert_eq!(ids, expected);
|
||||
assert_eq!(Panel::from_preset(panel.preset().unwrap()), *panel);
|
||||
}
|
||||
assert_eq!(continuous.start.groups.len(), 2);
|
||||
assert_eq!(continuous.end.groups.len(), 3);
|
||||
assert_eq!(islands.start.groups.len(), 1);
|
||||
assert_eq!(islands.end.groups.len(), 1);
|
||||
assert!(!islands.border && !continuous.border);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_filters_without_removing_or_reidentifying_instances() {
|
||||
let panel = Panel::default();
|
||||
let all = items(&panel);
|
||||
let visible = |availability| {
|
||||
all.iter()
|
||||
.filter(|item| item.available(availability))
|
||||
.map(|item| item.id.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
visible(Availability::default()),
|
||||
[
|
||||
"overview",
|
||||
"workspaces",
|
||||
"focused-window",
|
||||
"quick-settings",
|
||||
"night-light",
|
||||
"recording",
|
||||
"clock"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
visible(Availability {
|
||||
activities: true,
|
||||
media: true,
|
||||
network: true,
|
||||
audio: true,
|
||||
notifications: true,
|
||||
battery: true,
|
||||
external_display: true
|
||||
}),
|
||||
all.iter()
|
||||
.map(|item| item.id.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(items(&panel).len(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_map_to_instances_and_group_presentation() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
panel.center.groups[0].items[0].visible = false;
|
||||
let battery = panel.end.groups[0]
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.id.0 == "battery")
|
||||
.unwrap();
|
||||
battery.kind = ItemKind::Battery { percentage: false };
|
||||
for group in [&mut panel.start, &mut panel.center, &mut panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &mut zone.groups)
|
||||
{
|
||||
group.island_padding = 13.;
|
||||
}
|
||||
assert_eq!(
|
||||
panel.center.groups[0].items[0].kind,
|
||||
ItemKind::FocusedWindow
|
||||
);
|
||||
assert!(!panel.center.groups[0].items[0].visible);
|
||||
let all = items(&panel);
|
||||
assert_eq!(
|
||||
all.iter().find(|item| item.id.0 == "battery").unwrap().kind,
|
||||
ItemKind::Battery { percentage: false }
|
||||
);
|
||||
for zone in [&panel.start, &panel.center, &panel.end] {
|
||||
for group in &zone.groups {
|
||||
assert_eq!(group.island_padding, 13.0);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|item| item.id.0 == "clock")
|
||||
.unwrap()
|
||||
.gap_before,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clock_instances_have_distinct_identity() {
|
||||
let first = Item::new("main-clock", ItemKind::Clock);
|
||||
let second = Item::new("other-clock", ItemKind::Clock);
|
||||
assert_eq!(first.kind, second.kind);
|
||||
assert_ne!(first.id, second.id);
|
||||
}
|
||||
#[test]
|
||||
fn removed_item_fields_are_rejected() {
|
||||
for value in [
|
||||
serde_json::json!({"id":"title", "kind":"focused-window", "enabled":false}),
|
||||
serde_json::json!({"id":"spaces", "kind":"workspaces", "workspace_style":"numbers"}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<Item>(value).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_changes_only_replace_composition_when_item_geometry_changes() {
|
||||
let panel = Panel::default();
|
||||
let mut changed = panel.clone();
|
||||
changed.style = PanelStyle::Flat;
|
||||
assert!(panel.same_composition(&changed));
|
||||
changed.style = PanelStyle::Floating;
|
||||
assert!(!panel.same_composition(&changed));
|
||||
changed.style = PanelStyle::Flat;
|
||||
assert!(panel.same_composition(&changed));
|
||||
changed.end.groups[0].padding[1] += 1;
|
||||
assert!(!panel.same_composition(&changed));
|
||||
}
|
||||
}
|
||||
@@ -1,179 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
//! Theme values shared by the compositor, shell and Settings.
|
||||
mod color;
|
||||
pub mod families;
|
||||
mod resolved_theme;
|
||||
|
||||
pub use color::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Appearance {
|
||||
Light,
|
||||
#[default]
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Mode {
|
||||
Light,
|
||||
#[default]
|
||||
Dark,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct Accessibility {
|
||||
pub increase_contrast: bool,
|
||||
pub reduce_transparency: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Colors {
|
||||
pub surface_base: String,
|
||||
pub surface_raised: String,
|
||||
pub application_background: String,
|
||||
pub text_primary: String,
|
||||
pub text_muted: String,
|
||||
pub accent: String,
|
||||
pub on_accent: String,
|
||||
pub border: String,
|
||||
pub shadow: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Material {
|
||||
pub style: String,
|
||||
pub opacity: f64,
|
||||
pub blur_radius: f64,
|
||||
pub tint_strength: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub border_width: f64,
|
||||
pub focus_ring_width: f64,
|
||||
pub window_radius: f64,
|
||||
pub shell_radius: f64,
|
||||
pub control_gap: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Typography {
|
||||
pub font_family: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Background {
|
||||
pub path: Option<PathBuf>,
|
||||
pub lock_path: Option<PathBuf>,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct BarSurface {
|
||||
pub background: String,
|
||||
pub text_primary: String,
|
||||
pub text_muted: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Surfaces {
|
||||
pub bar: BarSurface,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct SoftShadow {
|
||||
pub offset_y: f64,
|
||||
pub blur: f64,
|
||||
pub opacity: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Shadows {
|
||||
pub soft: SoftShadow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Gradient {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaintStyle {
|
||||
#[default]
|
||||
Auto,
|
||||
Solid,
|
||||
}
|
||||
|
||||
impl PaintStyle {
|
||||
fn is_auto(&self) -> bool {
|
||||
*self == Self::Auto
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Paint {
|
||||
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
|
||||
pub style: PaintStyle,
|
||||
pub gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn effective_gradient(&self) -> Option<&Gradient> {
|
||||
if self.style == PaintStyle::Solid {
|
||||
None
|
||||
} else {
|
||||
self.gradient.as_ref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub colors: Colors,
|
||||
pub material: Material,
|
||||
pub geometry: Geometry,
|
||||
pub typography: Typography,
|
||||
pub background: Background,
|
||||
pub surface: Surfaces,
|
||||
pub shadow: Shadows,
|
||||
pub border: Paint,
|
||||
pub focus_ring: Paint,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct ResolvedTheme {
|
||||
pub appearance: Appearance,
|
||||
pub tokens: Tokens,
|
||||
pub requested_accent: String,
|
||||
pub accessibility: Accessibility,
|
||||
pub reduced_motion: bool,
|
||||
}
|
||||
|
||||
/// Accessibility takes precedence over a panel's authored opacity.
|
||||
pub fn effective_panel_opacity(
|
||||
inherited: f32,
|
||||
custom: Option<f32>,
|
||||
reduce_transparency: bool,
|
||||
) -> f32 {
|
||||
if reduce_transparency {
|
||||
1.0
|
||||
} else {
|
||||
custom.unwrap_or(inherited)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedTheme {
|
||||
pub fn panel_opacity(&self, custom: Option<f32>) -> f32 {
|
||||
let inherited = if self.tokens.material.style == "translucent" {
|
||||
self.tokens.material.opacity as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
effective_panel_opacity(inherited, custom, self.accessibility.reduce_transparency)
|
||||
}
|
||||
}
|
||||
pub use resolved_theme::*;
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Appearance {
|
||||
Light,
|
||||
#[default]
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Mode {
|
||||
Light,
|
||||
#[default]
|
||||
Dark,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct Accessibility {
|
||||
pub increase_contrast: bool,
|
||||
pub reduce_transparency: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Colors {
|
||||
pub surface_base: String,
|
||||
pub surface_raised: String,
|
||||
pub application_background: String,
|
||||
pub text_primary: String,
|
||||
pub text_muted: String,
|
||||
pub accent: String,
|
||||
pub on_accent: String,
|
||||
pub border: String,
|
||||
pub shadow: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Material {
|
||||
pub style: String,
|
||||
pub opacity: f64,
|
||||
pub blur_radius: f64,
|
||||
pub tint_strength: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub border_width: f64,
|
||||
pub focus_ring_width: f64,
|
||||
pub window_radius: f64,
|
||||
pub shell_radius: f64,
|
||||
pub control_gap: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Typography {
|
||||
pub font_family: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Background {
|
||||
pub path: Option<PathBuf>,
|
||||
pub lock_path: Option<PathBuf>,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct BarSurface {
|
||||
pub background: String,
|
||||
pub text_primary: String,
|
||||
pub text_muted: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Surfaces {
|
||||
pub bar: BarSurface,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct SoftShadow {
|
||||
pub offset_y: f64,
|
||||
pub blur: f64,
|
||||
pub opacity: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Shadows {
|
||||
pub soft: SoftShadow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Gradient {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaintStyle {
|
||||
#[default]
|
||||
Auto,
|
||||
Solid,
|
||||
}
|
||||
|
||||
impl PaintStyle {
|
||||
fn is_auto(&self) -> bool {
|
||||
*self == Self::Auto
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Paint {
|
||||
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
|
||||
pub style: PaintStyle,
|
||||
pub gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn effective_gradient(&self) -> Option<&Gradient> {
|
||||
if self.style == PaintStyle::Solid {
|
||||
None
|
||||
} else {
|
||||
self.gradient.as_ref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub colors: Colors,
|
||||
pub material: Material,
|
||||
pub geometry: Geometry,
|
||||
pub typography: Typography,
|
||||
pub background: Background,
|
||||
pub surface: Surfaces,
|
||||
pub shadow: Shadows,
|
||||
pub border: Paint,
|
||||
pub focus_ring: Paint,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct ResolvedTheme {
|
||||
pub appearance: Appearance,
|
||||
pub tokens: Tokens,
|
||||
pub requested_accent: String,
|
||||
pub accessibility: Accessibility,
|
||||
pub reduced_motion: bool,
|
||||
}
|
||||
|
||||
/// Accessibility takes precedence over a panel's authored opacity.
|
||||
pub fn effective_panel_opacity(
|
||||
inherited: f32,
|
||||
custom: Option<f32>,
|
||||
reduce_transparency: bool,
|
||||
) -> f32 {
|
||||
if reduce_transparency {
|
||||
1.0
|
||||
} else {
|
||||
custom.unwrap_or(inherited)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedTheme {
|
||||
pub fn panel_opacity(&self, custom: Option<f32>) -> f32 {
|
||||
let inherited = if self.tokens.material.style == "translucent" {
|
||||
self.tokens.material.opacity as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
effective_panel_opacity(inherited, custom, self.accessibility.reduce_transparency)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user