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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ How that movement behaves depends on what you’re doing. While dragging, the wi
|
||||
|
||||
## Keeping layout, animation, and application buffers in step
|
||||
|
||||
The resize example explains why [WindowGeometry](../crates/ferese-animation/src/lib.rs) keeps three states. Its logical rectangle records the destination requested by layout or a presentation mode, such as fullscreen. Its visual rectangle records what is moving toward that destination, including the current bounds and velocity. Client geometry tracks the size Ferese requested from the application and the size the application has committed.
|
||||
The resize example explains why [WindowGeometry](../crates/ferese-animation/src/window_geometry.rs) keeps three states. Its logical rectangle records the destination requested by layout or a presentation mode, such as fullscreen. Its visual rectangle records what is moving toward that destination, including the current bounds and velocity. Client geometry tracks the size Ferese requested from the application and the size the application has committed.
|
||||
|
||||
Keeping these separate means the compositor can request the destination size as soon as the layout changes. It does not have to wait for the animation to finish before asking the application to draw. The new buffer might arrive before or after the visible rectangle settles, so retained images cover the resize handoff while presentation continues independently.
|
||||
|
||||
@@ -130,7 +130,7 @@ After settlement, hover redraws reuse animation content when the motion sample h
|
||||
|
||||
## Checking the model against what is drawn
|
||||
|
||||
The [animation trace tests](../crates/ferese-animation/src/traces.rs) follow motion through stalls, changing frame cadence, retargeting, resize interruption, and gesture release. Solver tests compare the results with an independent characteristic-root reference, while gesture tests check projection and the rubber-band derivative. Compositor tests then check how that motion is used for presentation ownership, predicted geometry, workspace switching, and reduced motion.
|
||||
The [animation trace tests](../crates/ferese-animation/src/window_geometry/traces.rs) follow motion through stalls, changing frame cadence, retargeting, resize interruption, and gesture release. Solver tests compare the results with an independent characteristic-root reference, while gesture tests check projection and the rubber-band derivative. Compositor tests then check how that motion is used for presentation ownership, predicted geometry, workspace switching, and reduced motion.
|
||||
|
||||
Rendering checks need to follow the interruptions people can make while using the desktop: reversing a transition, clicking several overview workspaces, closing during resize, or selecting a shell panel before the previous motion settles. At fractional output scales, those checks must also look for separation between content, clip, and border, and for background or blur remaining after shell content exits. Frame traces should confirm that animation requests stop after settlement.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::Path;
|
||||
|
||||
fn prepare(source: &str) -> Result<String, Box<dyn Error>> {
|
||||
let mut doc = Document::parse(source)?;
|
||||
if !ferese_config::activities::setup_shell_hook(&mut doc)? {
|
||||
if !doc.setup_shell_hook()? {
|
||||
return Ok(source.into());
|
||||
}
|
||||
if doc.get("panels").is_none() {
|
||||
|
||||
Reference in new issue
Block a user