Format Rust sources at 100 columns

This commit is contained in:
Samuel Onoja committed 2026-10-22 01:28:03 +01:00
1 parent 4661f595f8
commit 610478f8c7
336 files changed
+23497 -7892

No files matched your search

+87 -20
View File
@@ -28,7 +28,12 @@ pub struct Id {
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "kind", content = "value", rename_all = "kebab-case", deny_unknown_fields)]
#[serde(
tag = "kind",
content = "value",
rename_all = "kebab-case",
deny_unknown_fields
)]
pub enum Value {
Indeterminate,
Progress(f32),
@@ -51,7 +56,12 @@ impl Value {
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "kind", content = "data", rename_all = "kebab-case", deny_unknown_fields)]
#[serde(
tag = "kind",
content = "data",
rename_all = "kebab-case",
deny_unknown_fields
)]
pub enum Template {
Progress,
Timer,
@@ -96,20 +106,39 @@ pub struct Data {
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()) {
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'-') {
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"
"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) => {
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)?,
@@ -128,15 +157,28 @@ impl Data {
_ => {}
}
match &self.template {
Template::Timer if !matches!(self.value, Value::Countdown { .. } | Value::PausedCountdown { .. }) => {
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 { .. }) => {
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(_)) {
if !matches!(
self.value,
Value::Text(_) | Value::Indeterminate | Value::Progress(_)
) {
return Err("invalid media value");
}
}
@@ -211,7 +253,9 @@ impl Activity {
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::Done | State::Failed => {
!dnd && self.result_deadline.is_some_and(|deadline| now < deadline)
}
State::Cancelled => false,
}
}
@@ -258,7 +302,9 @@ impl Store {
}
pub fn resolve(&self, owner: Owner, local: &str) -> Option<&Id> {
self.entries.keys().find(|id| id.owner == owner && id.local == local)
self.entries
.keys()
.find(|id| id.owner == owner && id.local == local)
}
#[allow(clippy::too_many_arguments)]
@@ -290,7 +336,10 @@ impl Store {
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")?;
self.generation = self
.generation
.checked_add(1)
.ok_or("generation exhausted")?;
let id = Id {
owner,
local,
@@ -351,7 +400,8 @@ impl Store {
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.lease_deadline =
Some(now.saturating_add(deadline.saturating_sub(entry.refreshed_at)));
}
entry.refreshed_at = now;
Ok(())
@@ -380,7 +430,10 @@ impl Store {
Err("terminal result already accepted")
};
}
if state == State::Cancelled || now < entry.eligible_at || (state == State::Failed && !entry.flash_on_fail) {
if state == State::Cancelled
|| now < entry.eligible_at
|| (state == State::Failed && !entry.flash_on_fail)
{
self.entries.remove(id);
return Ok(());
}
@@ -409,7 +462,11 @@ impl Store {
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()) {
for entry in self
.entries
.values_mut()
.filter(|entry| entry.data.output.is_none())
{
entry.data.output = Some(output.into());
changed = true;
}
@@ -433,9 +490,11 @@ impl Store {
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)),
entry.result_deadline.map(|deadline| {
deadline
.saturating_sub(entry.result_flash_ms)
.saturating_add(400)
}),
]
})
.flatten()
@@ -497,7 +556,13 @@ impl Selection {
pub fn override_pin(&mut self) {
self.pinned = None;
}
pub fn select(&mut self, ranked: &[&Activity], limits: Limits, dnd: bool, protected_slots: usize) {
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();
@@ -557,7 +622,9 @@ pub fn reconcile_focus(focus: &mut Option<Id>, previous: &[Id], current: &[Id])
.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();
*focus = current
.get(index.min(current.len().saturating_sub(1)))
.cloned();
}
#[cfg(test)]
+12 -2
View File
@@ -131,7 +131,11 @@ impl Session {
if let Some(phase) = phase {
self.phase = phase;
self.remaining_ms = self.settings.duration(phase);
self.status = if running { Status::Running } else { Status::Ready };
self.status = if running {
Status::Running
} else {
Status::Ready
};
if running {
self.deadline = Some(now.saturating_add(self.remaining_ms));
}
@@ -210,7 +214,13 @@ mod tests {
] {
let deadline = session.deadline.unwrap();
assert!(session.advance(deadline - 1).is_none());
assert_eq!(session.advance(deadline), Some(Completion { phase: finished, next }));
assert_eq!(
session.advance(deadline),
Some(Completion {
phase: finished,
next
})
);
assert_eq!(session.completed_work, count);
assert!(session.advance(deadline + 99_000_000).is_none());
if next.is_some() {
+72 -12
View File
@@ -1,7 +1,10 @@
use super::*;
fn owner() -> Owner {
Owner { pid: 42, birth: 100 }
Owner {
pid: 42,
birth: 100,
}
}
fn data(priority: u8) -> Data {
Data {
@@ -84,9 +87,21 @@ fn fast_failures_cancel_and_suppressed_failure_never_flash() {
}
let mut store = Store::new(1);
let id = store
.start(owner(), "a".into(), data(100), 0, 0, None, true, false, None)
.start(
owner(),
"a".into(),
data(100),
0,
0,
None,
true,
false,
None,
)
.unwrap();
store
.finish(owner(), &id, State::Failed, None, 300)
.unwrap();
store.finish(owner(), &id, State::Failed, None, 300).unwrap();
assert_eq!(store.entries().count(), 0);
}
@@ -98,7 +113,9 @@ fn result_retry_and_owner_exit_preserve_original_deadline() {
.finish(owner(), &id, State::Failed, Some("Failed".into()), 300)
.unwrap();
assert_eq!(store.entries().next().unwrap().score(300, 255), 385);
store.finish(owner(), &id, State::Failed, None, 1_000).unwrap();
store
.finish(owner(), &id, State::Failed, None, 1_000)
.unwrap();
store.owner_exit(owner());
assert_eq!(store.entries().next().unwrap().result_deadline, Some(1_800));
assert!(!store.advance(1_799));
@@ -110,7 +127,17 @@ fn result_retry_and_owner_exit_preserve_original_deadline() {
fn leases_refresh_but_abandonment_never_claims_a_result() {
let mut store = Store::new(1);
let id = store
.start(owner(), "a".into(), data(100), 0, 0, Some(500), false, true, None)
.start(
owner(),
"a".into(),
data(100),
0,
0,
Some(500),
false,
true,
None,
)
.unwrap();
store.update(owner(), &id, None, None, 499).unwrap();
assert_eq!(store.entries().next().unwrap().lease_deadline, Some(999));
@@ -132,7 +159,10 @@ fn leases_refresh_but_abandonment_never_claims_a_result() {
fn owner_session_generation_and_quotas_are_independent_of_connections() {
let mut store = Store::new(12);
let id = start(&mut store, "a", 0, 100);
let other = Owner { pid: 42, birth: 101 };
let other = Owner {
pid: 42,
birth: 101,
};
assert!(store.update(other, &id, None, None, 0).is_err());
let mut stale = id.clone();
stale.session = 11;
@@ -149,7 +179,9 @@ fn owner_session_generation_and_quotas_are_independent_of_connections() {
.start(owner(), "d".into(), data(100), 0, 0, None, true, true, None)
.is_err()
);
store.finish(owner(), &id, State::Cancelled, None, 0).unwrap();
store
.finish(owner(), &id, State::Cancelled, None, 0)
.unwrap();
let replacement = start(&mut store, "a", 0, 100);
assert_ne!(replacement.generation, id.generation);
assert!(store.update(owner(), &id, None, None, 0).is_err());
@@ -215,7 +247,10 @@ fn dnd_protected_slots_and_output_routing() {
assert_eq!(selection.hidden, 2);
store.remove_output("one", Some("two"));
assert!(ranked(store.entries(), 300, store.limits, "one", false).is_empty());
assert_eq!(ranked(store.entries(), 300, store.limits, "two", false).len(), 2);
assert_eq!(
ranked(store.entries(), 300, store.limits, "two", false).len(),
2
);
}
#[test]
@@ -280,9 +315,21 @@ fn notification_boost_cannot_outrank_ordinary_and_grouped_lifetimes_remain_indep
let mut notification = data(255);
notification.source = Source::Notification;
let notice = store
.start(owner(), "notice".into(), notification, 0, 0, None, true, true, None)
.start(
owner(),
"notice".into(),
notification,
0,
0,
None,
true,
true,
None,
)
.unwrap();
store
.finish(owner(), &notice, State::Failed, None, 300)
.unwrap();
store.finish(owner(), &notice, State::Failed, None, 300).unwrap();
let entries = ranked(store.entries(), 300, store.limits, "one", false);
assert_eq!(entries.last().unwrap().id, notice);
let mut selection = Selection::default();
@@ -298,14 +345,27 @@ fn notification_boost_cannot_outrank_ordinary_and_grouped_lifetimes_remain_indep
);
store.remove_output("one", None);
assert!(store.attach_output("two"));
assert_eq!(ranked(store.entries(), 301, store.limits, "two", false).len(), 3);
assert_eq!(
ranked(store.entries(), 301, store.limits, "two", false).len(),
3
);
}
#[test]
fn invalid_updates_are_atomic_and_suspend_aware_eligibility_hides_expired_records() {
let mut store = Store::new(1);
let id = store
.start(owner(), "atomic".into(), data(100), 0, 0, Some(1_000), true, true, None)
.start(
owner(),
"atomic".into(),
data(100),
0,
0,
Some(1_000),
true,
true,
None,
)
.unwrap();
assert!(
store
+18 -4
View File
@@ -59,7 +59,11 @@ impl VelocityTracker {
if divisor.abs() < 1e-12 {
let (first, position) = self.samples[0];
let dt = last.saturating_sub(first).as_secs_f64();
return if dt > 0.0 { (origin - position) / dt } else { 0.0 };
return if dt > 0.0 {
(origin - position) / dt
} else {
0.0
};
}
for entry in &mut matrix[col][col..=degree + 1] {
*entry /= divisor;
@@ -70,7 +74,10 @@ impl VelocityTracker {
continue;
}
let factor = values[col];
for (entry, pivot) in values[col..=degree + 1].iter_mut().zip(&pivot_row[col..=degree + 1]) {
for (entry, pivot) in values[col..=degree + 1]
.iter_mut()
.zip(&pivot_row[col..=degree + 1])
{
*entry -= factor * pivot;
}
}
@@ -89,7 +96,12 @@ pub fn project(position: f64, velocity: f64, rate: f64) -> f64 {
/// Nonlinear resistance, bounded by `extent`; the derivative transfers the
/// velocity of the displayed (resisted) position into the release spring.
pub fn rubber_band(distance: f64, extent: f64, resistance: f64) -> (f64, f64) {
if !distance.is_finite() || !extent.is_finite() || extent <= 0.0 || !resistance.is_finite() || resistance <= 0.0 {
if !distance.is_finite()
|| !extent.is_finite()
|| extent <= 0.0
|| !resistance.is_finite()
|| resistance <= 0.0
{
return (0.0, 0.0);
}
let denominator = 1.0 + resistance * distance.abs() / extent;
@@ -108,7 +120,9 @@ mod tests {
let (position, derivative) = rubber_band(x, 64.0, 0.55);
assert!((0.0..64.0).contains(&position));
assert_eq!(rubber_band(-x, 64.0, 0.55).0, -position);
let measured = (rubber_band(x + 0.0001, 64.0, 0.55).0 - rubber_band(x - 0.0001, 64.0, 0.55).0) / 0.0002;
let measured = (rubber_band(x + 0.0001, 64.0, 0.55).0
- rubber_band(x - 0.0001, 64.0, 0.55).0)
/ 0.0002;
assert!((measured - derivative).abs() < 1e-6);
}
}
+125 -27
View File
@@ -113,7 +113,12 @@ impl AnimatedValue {
self.advance_with_policy(delta, config, config.crossing)
}
pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool {
pub fn advance_with_policy(
&mut self,
delta: Duration,
config: SpringConfig,
policy: CrossingPolicy,
) -> bool {
let config = config.normalized();
let settled = |value: &Self| {
(value.current - value.target).abs() <= config.position_tolerance
@@ -167,7 +172,12 @@ impl AnimatedRect {
self.advance_with_policy(delta, config, config.crossing)
}
pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool {
pub fn advance_with_policy(
&mut self,
delta: Duration,
config: SpringConfig,
policy: CrossingPolicy,
) -> bool {
let config = config.normalized();
if delta.is_zero() {
return !self.is_settled(config);
@@ -278,7 +288,8 @@ impl ClientGeometry {
/// The hard bound for the current uninterrupted client-buffer wait.
pub fn commit_deadline(&self) -> Option<Duration> {
self.waiting_since.map(|started| started + CLIENT_COMMIT_TIMEOUT)
self.waiting_since
.map(|started| started + CLIENT_COMMIT_TIMEOUT)
}
pub fn timed_out(&self, now: Duration) -> bool {
@@ -354,7 +365,12 @@ impl WindowGeometry {
self.client.request_size(size, now).then_some(size)
}
pub fn set_presentation_target(&mut self, rect: Rect, fullscreen: bool, now: Duration) -> Option<ClientSize> {
pub fn set_presentation_target(
&mut self,
rect: Rect,
fullscreen: bool,
now: Duration,
) -> Option<ClientSize> {
self.set_presentation_mode(
rect,
if fullscreen {
@@ -366,7 +382,12 @@ impl WindowGeometry {
)
}
pub fn set_presentation_mode(&mut self, rect: Rect, mode: PresentationMode, now: Duration) -> Option<ClientSize> {
pub fn set_presentation_mode(
&mut self,
rect: Rect,
mode: PresentationMode,
now: Duration,
) -> Option<ClientSize> {
let rect = normalized_rect(rect);
let before = (self.visual.current, self.decorations);
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
@@ -374,7 +395,11 @@ impl WindowGeometry {
.zoom
.map(|zoom| zoom.decorations)
.unwrap_or_else(|| AnimatedValue::new(self.decorations));
decorations.set_target(if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 });
decorations.set_target(if mode == PresentationMode::Fullscreen {
0.0
} else {
1.0
});
self.zoom = Some(ZoomTransition { decorations });
}
self.mode = mode;
@@ -416,14 +441,24 @@ impl WindowGeometry {
requested_size
}
pub fn advance(&mut self, delta: Duration, config: SpringConfig, animations_enabled: bool) -> bool {
pub fn advance(
&mut self,
delta: Duration,
config: SpringConfig,
animations_enabled: bool,
) -> bool {
let previous = (self.visual.current, self.decorations);
let active = self.advance_presentation(delta, config, animations_enabled);
self.presentation_changed = previous != (self.visual.current, self.decorations);
active
}
fn advance_presentation(&mut self, delta: Duration, config: SpringConfig, animations_enabled: bool) -> bool {
fn advance_presentation(
&mut self,
delta: Duration,
config: SpringConfig,
animations_enabled: bool,
) -> bool {
if !animations_enabled {
self.visual.snap();
self.decorations = if self.is_fullscreen() { 0.0 } else { 1.0 };
@@ -483,7 +518,9 @@ fn advance_property(
config: SpringConfig,
policy: CrossingPolicy,
) {
if policy == CrossingPolicy::NoCrossing && spring::crosses(*current, *velocity, target, dt, config) {
if policy == CrossingPolicy::NoCrossing
&& spring::crosses(*current, *velocity, target, dt, config)
{
*current = target;
*velocity = 0.0;
} else {
@@ -492,7 +529,13 @@ fn advance_property(
}
#[cfg(test)]
fn advance_value(current: &mut f64, target: f64, velocity: &mut f64, dt: f64, config: SpringConfig) {
fn advance_value(
current: &mut f64,
target: f64,
velocity: &mut f64,
dt: f64,
config: SpringConfig,
) {
(*current, *velocity) = spring::solve(*current, *velocity, target, dt, config);
}
@@ -560,16 +603,31 @@ mod tests {
));
}
assert_eq!(client.commit_deadline(), Some(Duration::from_millis(500)));
assert!(!client.commit(ClientSize { width: 49, height: 100 }));
assert!(!client.commit(ClientSize {
width: 49,
height: 100
}));
assert!(client.expire_wait(Duration::from_millis(499)).is_none());
assert_eq!(
client.expire_wait(Duration::from_millis(500)),
Some(ClientSize { width: 50, height: 100 })
Some(ClientSize {
width: 50,
height: 100
})
);
assert_eq!(client.commit_deadline(), None);
assert!(client.request_size(ClientSize { width: 51, height: 100 }, Duration::from_millis(600)));
assert!(client.request_size(
ClientSize {
width: 51,
height: 100
},
Duration::from_millis(600)
));
assert_eq!(client.commit_deadline(), Some(Duration::from_millis(1100)));
assert!(client.commit(ClientSize { width: 51, height: 100 }));
assert!(client.commit(ClientSize {
width: 51,
height: 100
}));
assert_eq!(client.commit_deadline(), None);
}
@@ -634,10 +692,17 @@ mod tests {
assert_eq!(geometry.visual.current, rect);
assert_eq!(
geometry.decorations,
if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 }
if mode == PresentationMode::Fullscreen {
0.0
} else {
1.0
}
);
assert!(!geometry.is_zooming());
assert_eq!(geometry.is_fullscreen(), mode == PresentationMode::Fullscreen);
assert_eq!(
geometry.is_fullscreen(),
mode == PresentationMode::Fullscreen
);
}
}
@@ -664,7 +729,9 @@ mod tests {
fn zoom_finishes_without_a_visible_final_edge_jump() {
let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0);
let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0);
for (start, target, fullscreen_target) in [(tiled, fullscreen, true), (fullscreen, tiled, false)] {
for (start, target, fullscreen_target) in
[(tiled, fullscreen, true), (fullscreen, tiled, false)]
{
let mut geometry = WindowGeometry::new(start, None);
if !fullscreen_target {
geometry.set_presentation_target(start, true, Duration::ZERO);
@@ -674,7 +741,11 @@ mod tests {
let mut finished = false;
for _ in 0..1000 {
let before = geometry.visual.current;
if !geometry.advance(Duration::from_secs_f64(1.0 / 144.0), SpringConfig::default(), true) {
if !geometry.advance(
Duration::from_secs_f64(1.0 / 144.0),
SpringConfig::default(),
true,
) {
let right_before = before.x + before.width;
let right_after = target.x + target.width;
assert!(
@@ -701,7 +772,9 @@ mod tests {
let progress = 1.0 - geometry.decorations;
let visual = geometry.visual.current;
assert!((visual.x - (start.x + (end.x - start.x) * progress)).abs() < 1e-9);
assert!((visual.width - (start.width + (end.width - start.width) * progress)).abs() < 1e-9);
assert!(
(visual.width - (start.width + (end.width - start.width) * progress)).abs() < 1e-9
);
}
}
@@ -712,7 +785,11 @@ mod tests {
let mut geometry = WindowGeometry::new(tiled, None);
for enabled in [true, false, true, false] {
let before = (geometry.visual.current, geometry.decorations);
geometry.set_presentation_target(if enabled { fullscreen } else { tiled }, enabled, Duration::ZERO);
geometry.set_presentation_target(
if enabled { fullscreen } else { tiled },
enabled,
Duration::ZERO,
);
assert_eq!((geometry.visual.current, geometry.decorations), before);
geometry.advance(Duration::from_millis(48), SpringConfig::default(), true);
}
@@ -756,15 +833,24 @@ mod tests {
Some(ClientSize::from_rect(middle))
);
geometry.advance(Duration::from_millis(64), SpringConfig::default(), true);
assert_eq!(geometry.presentation_size_request(Duration::from_millis(64)), None);
assert_eq!(
geometry.set_presentation_mode(end, PresentationMode::Normal, Duration::from_millis(64)),
geometry.presentation_size_request(Duration::from_millis(64)),
None
);
assert_eq!(
geometry.set_presentation_mode(
end,
PresentationMode::Normal,
Duration::from_millis(64)
),
Some(ClientSize::from_rect(end))
);
let mut requests = Vec::new();
for frame in 1..240 {
geometry.advance(Duration::from_millis(8), SpringConfig::default(), true);
if let Some(size) = geometry.presentation_size_request(Duration::from_millis(64 + frame * 8)) {
if let Some(size) =
geometry.presentation_size_request(Duration::from_millis(64 + frame * 8))
{
assert_eq!(geometry.visual.current.width, end.width);
requests.push(size);
}
@@ -809,8 +895,13 @@ mod tests {
true,
);
let current = geometry.visual.current;
let edges = |rect: Rect| [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height];
for ((before, after), end) in edges(previous).into_iter().zip(edges(current)).zip(edges(target))
let edges = |rect: Rect| {
[rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]
};
for ((before, after), end) in edges(previous)
.into_iter()
.zip(edges(current))
.zip(edges(target))
{
assert!((after - end).abs() <= (before - end).abs() + 1e-9);
}
@@ -967,7 +1058,11 @@ mod tests {
let mut geometry = WindowGeometry::new(rect, None);
assert!(geometry.set_logical_target(rect, Duration::ZERO).is_some());
assert!(geometry.set_logical_target(rect, Duration::from_millis(16)).is_none());
assert!(
geometry
.set_logical_target(rect, Duration::from_millis(16))
.is_none()
);
}
#[test]
@@ -1026,7 +1121,10 @@ mod tests {
assert!(sixty.current.width <= target.width);
}
for _ in 0..288 {
one_forty_four.advance(Duration::from_secs_f64(1.0 / 144.0), SpringConfig::default());
one_forty_four.advance(
Duration::from_secs_f64(1.0 / 144.0),
SpringConfig::default(),
);
}
assert!(sixty.is_settled(SpringConfig::default()));
+14 -2
View File
@@ -117,7 +117,16 @@ mod tests {
#[test]
fn speed_validation_rejects_extremes_and_accepts_safe_boundaries() {
for speed in [f64::NAN, f64::INFINITY, 0.0, -1.0, 1e-100, 1e100, 0.009, 101.0] {
for speed in [
f64::NAN,
f64::INFINITY,
0.0,
-1.0,
1e-100,
1e100,
0.009,
101.0,
] {
let settings = MotionSettings {
speed,
..Default::default()
@@ -164,7 +173,10 @@ mod tests {
.unwrap();
assert!((spring.stiffness - 157.913670417).abs() < 1e-8);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10);
assert!((std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs() < 1e-10);
assert!(
(std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs()
< 1e-10
);
}
}
+25 -5
View File
@@ -2,7 +2,13 @@ use super::SpringConfig;
/// Solve m y'' + c y' + k y = 0, with y = current - target.
/// No settling tolerance or crossing clamp is applied here.
pub(super) fn solve(current: f64, velocity: f64, target: f64, dt: f64, config: SpringConfig) -> (f64, f64) {
pub(super) fn solve(
current: f64,
velocity: f64,
target: f64,
dt: f64,
config: SpringConfig,
) -> (f64, f64) {
if dt <= 0.0 || !dt.is_finite() {
return (current, velocity);
}
@@ -20,7 +26,8 @@ pub(super) fn solve(current: f64, velocity: f64, target: f64, dt: f64, config: S
// rather than replacing a neighborhood with the critical solution.
if z2.abs() < 0.01 {
let c = 1.0 + z2 * (0.5 + z2 * (1.0 / 24.0 + z2 * (1.0 / 720.0 + z2 / 40320.0)));
let s = dt * (1.0 + z2 * (1.0 / 6.0 + z2 * (1.0 / 120.0 + z2 * (1.0 / 5040.0 + z2 / 362880.0))));
let s = dt
* (1.0 + z2 * (1.0 / 6.0 + z2 * (1.0 / 120.0 + z2 * (1.0 / 5040.0 + z2 / 362880.0))));
let e = (-a * dt).exp();
return (
target + e * (y * c + (velocity + a * y) * s),
@@ -46,13 +53,22 @@ pub(super) fn solve(current: f64, velocity: f64, target: f64, dt: f64, config: S
let fast_coefficient = (slow * y - velocity) / (2.0 * q);
let slow_term = slow_coefficient * (slow * dt).exp();
let fast_term = fast_coefficient * (fast * dt).exp();
(target + slow_term + fast_term, slow * slow_term + fast * fast_term)
(
target + slow_term + fast_term,
slow * slow_term + fast * fast_term,
)
}
}
/// True if the physical trajectory reaches equilibrium anywhere in (0, dt].
/// Endpoint-only tests miss an even number of crossings after a long stall.
pub(super) fn crosses(current: f64, velocity: f64, target: f64, dt: f64, config: SpringConfig) -> bool {
pub(super) fn crosses(
current: f64,
velocity: f64,
target: f64,
dt: f64,
config: SpringConfig,
) -> bool {
if dt <= 0.0 {
return false;
}
@@ -70,7 +86,11 @@ pub(super) fn crosses(current: f64, velocity: f64, target: f64, dt: f64, config:
let crossing = if d < 0.0 {
let w = (-d).sqrt();
let phase = (-y * w).atan2(b).rem_euclid(std::f64::consts::PI);
let phase = if phase == 0.0 { std::f64::consts::PI } else { phase };
let phase = if phase == 0.0 {
std::f64::consts::PI
} else {
phase
};
phase / w
} else if y == 0.0 || b == 0.0 {
return false;
+100 -21
View File
@@ -25,7 +25,12 @@ impl FakeClock {
}
}
fn sample(&mut self, time: Duration, value: &mut AnimatedValue, config: SpringConfig) -> Sample {
fn sample(
&mut self,
time: Duration,
value: &mut AnimatedValue,
config: SpringConfig,
) -> Sample {
assert!(time >= self.now);
self.now = time;
let delta = time - self.last;
@@ -58,7 +63,8 @@ impl FakeClock {
fn legacy_advance(value: &mut AnimatedValue, delta: Duration, config: SpringConfig) {
let config = config.normalized();
let settled = |v: &AnimatedValue| {
(v.current - v.target).abs() <= config.position_tolerance && v.velocity.abs() <= config.velocity_tolerance
(v.current - v.target).abs() <= config.position_tolerance
&& v.velocity.abs() <= config.velocity_tolerance
};
if settled(value) {
value.snap();
@@ -70,7 +76,8 @@ fn legacy_advance(value: &mut AnimatedValue, delta: Duration, config: SpringConf
let dt = seconds / steps as f64;
for _ in 0..steps {
let old = value.current - value.target;
value.velocity += (-config.stiffness * old - config.damping * value.velocity) / config.mass * dt;
value.velocity +=
(-config.stiffness * old - config.damping * value.velocity) / config.mass * dt;
value.current += value.velocity * dt;
if old != 0.0 && old.signum() != (value.current - value.target).signum() {
value.snap();
@@ -91,7 +98,11 @@ fn capture_old_golden_trace_and_retarget_policy() {
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
for i in 1..=18 {
legacy_advance(&mut value, Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
legacy_advance(
&mut value,
Duration::from_secs_f64(1.0 / 60.0),
SpringConfig::default(),
);
if [1, 3, 6, 12, 18].contains(&i) {
println!("old normal {i}: {}, {}", value.current, value.velocity);
}
@@ -99,7 +110,11 @@ fn capture_old_golden_trace_and_retarget_policy() {
let mut value = AnimatedValue::new(0.0);
value.set_target(1000.0);
legacy_advance(&mut value, Duration::from_millis(40), SpringConfig::default());
legacy_advance(
&mut value,
Duration::from_millis(40),
SpringConfig::default(),
);
println!("old retarget before: {}, {}", value.current, value.velocity);
let position = value.current;
let velocity = value.velocity;
@@ -127,7 +142,11 @@ fn long_stall_uses_the_whole_elapsed_duration() {
let sample = clock.sample(Duration::from_millis(416), &mut value, config);
let t: f64 = 0.416;
let decay = (-10.0 * t).exp();
close(sample.current, 500.0 - 500.0 * (1.0 + 10.0 * t) * decay, 1e-8);
close(
sample.current,
500.0 - 500.0 * (1.0 + 10.0 * t) * decay,
1e-8,
);
close(sample.velocity, 50000.0 * t * decay, 1e-8);
assert_eq!(sample.time, Duration::from_millis(416));
assert_eq!(sample.target, 500.0);
@@ -147,13 +166,25 @@ fn fake_clock_pause_excludes_three_hundred_milliseconds() {
let mut clock = FakeClock::new();
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
clock.sample(Duration::from_millis(40), &mut value, SpringConfig::default());
clock.sample(
Duration::from_millis(40),
&mut value,
SpringConfig::default(),
);
let frozen = value;
clock.pause();
clock.sample(Duration::from_millis(340), &mut value, SpringConfig::default());
clock.sample(
Duration::from_millis(340),
&mut value,
SpringConfig::default(),
);
assert_eq!(value, frozen);
clock.resume(Duration::from_millis(340));
clock.sample(Duration::from_millis(356), &mut value, SpringConfig::default());
clock.sample(
Duration::from_millis(356),
&mut value,
SpringConfig::default(),
);
let mut expected = frozen;
expected.advance(Duration::from_millis(16), SpringConfig::default());
assert_eq!(value, expected);
@@ -182,7 +213,10 @@ fn reference(x: f64, v: f64, target: f64, dt: f64, config: SpringConfig) -> (f64
let b = (v + a * y) / w;
let (s, c) = (w * dt).sin_cos();
let e = (-a * dt).exp();
(target + e * (y * c + b * s), e * (v * c - (a * v + omega2 * y) / w * s))
(
target + e * (y * c + b * s),
e * (v * c - (a * v + omega2 * y) / w * s),
)
} else {
let q = d.sqrt();
let r1 = -a + q;
@@ -197,7 +231,15 @@ fn reference(x: f64, v: f64, target: f64, dt: f64, config: SpringConfig) -> (f64
#[test]
fn solver_reference_regimes_initial_states_and_times() {
for damping in [0.0, 5.0, 20.0, 20.0 * (1.0 - 1e-9), 20.0 * (1.0 + 1e-9), 53.0, 2000.0] {
for damping in [
0.0,
5.0,
20.0,
20.0 * (1.0 - 1e-9),
20.0 * (1.0 + 1e-9),
53.0,
2000.0,
] {
let config = SpringConfig {
stiffness: 100.0,
damping,
@@ -256,7 +298,11 @@ fn normal_trace_records_the_old_numerical_error() {
old.set_target(500.0);
let mut max_error: f64 = 0.0;
for i in 1..=18 {
legacy_advance(&mut old, Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
legacy_advance(
&mut old,
Duration::from_secs_f64(1.0 / 60.0),
SpringConfig::default(),
);
if let Some((_, x, v)) = gold.iter().find(|(n, _, _)| *n == i) {
close(old.current, *x, 1e-9);
close(old.velocity, *v, 1e-9);
@@ -303,7 +349,11 @@ fn gesture_flick_trace_crosses_once_and_settles_while_clamp_stops_at_crossing()
let mut crossings = 0;
let mut previous_error: f64 = -100.0;
for ms in 1..=1500 {
free.advance_with_policy(Duration::from_millis(1), config, CrossingPolicy::AllowOvershoot);
free.advance_with_policy(
Duration::from_millis(1),
config,
CrossingPolicy::AllowOvershoot,
);
clamped.advance_with_policy(Duration::from_millis(1), config, CrossingPolicy::NoCrossing);
let error = free.current - free.target;
if error != 0.0 && previous_error.signum() != error.signum() {
@@ -332,17 +382,29 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
let mut clock = FakeClock::new();
let mut value = AnimatedValue::new(0.0);
value.set_target(1000.0);
clock.sample(Duration::from_millis(40), &mut value, SpringConfig::default());
clock.sample(
Duration::from_millis(40),
&mut value,
SpringConfig::default(),
);
let before = value;
value.retarget_preserving_motion(2000.0);
assert_eq!(value.current, before.current);
assert_eq!(value.velocity, before.velocity);
clock.sample(Duration::from_millis(65), &mut value, SpringConfig::default());
clock.sample(
Duration::from_millis(65),
&mut value,
SpringConfig::default(),
);
let before = value;
value.retarget_preserving_motion(-500.0);
assert_eq!(value.current, before.current);
assert_eq!(value.velocity, before.velocity);
clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default());
clock.sample(
Duration::from_millis(81),
&mut value,
SpringConfig::default(),
);
assert_ne!(value.velocity, 0.0);
// Apply the current policy to the exact old in-flight state too.
@@ -366,7 +428,11 @@ fn interrupted_resize_and_zoom_targets_preserve_position_and_velocity() {
] {
let before = (geometry.visual.current, geometry.decorations);
let velocity = geometry.visual.velocity;
geometry.set_presentation_mode(Rect::new(200., 100., 700., 500.), mode, Duration::from_millis(40));
geometry.set_presentation_mode(
Rect::new(200., 100., 700., 500.),
mode,
Duration::from_millis(40),
);
assert_eq!((geometry.visual.current, geometry.decorations), before);
assert_eq!(geometry.visual.velocity, velocity);
geometry.advance(Duration::ZERO, SpringConfig::default(), true);
@@ -418,7 +484,10 @@ fn variable_cadence_traces_match_reference_at_every_absolute_timestamp() {
reference_settle_us += 100;
}
let exact_settle = Duration::from_micros(reference_settle_us);
assert!(settled_at.abs_diff(exact_settle) <= Duration::from_micros(*schedule.iter().max().unwrap() + 100));
assert!(
settled_at.abs_diff(exact_settle)
<= Duration::from_micros(*schedule.iter().max().unwrap() + 100)
);
}
}
@@ -476,7 +545,10 @@ fn solver_is_finite_for_realistic_ranges_and_invalid_configuration_falls_back()
..SpringConfig::default()
};
let (x, v) = physical(-10000., 50000., 10000., dt, config);
assert!(x.is_finite() && v.is_finite(), "{config:?} dt {dt}: {x} {v}");
assert!(
x.is_finite() && v.is_finite(),
"{config:?} dt {dt}: {x} {v}"
);
}
}
}
@@ -514,7 +586,10 @@ fn solver_is_finite_for_realistic_ranges_and_invalid_configuration_falls_back()
#[test]
fn physical_zero_and_negative_time_do_not_move_state() {
for dt in [0.0, -0.01] {
assert_eq!(physical(5.0, 100.0, 10.0, dt, SpringConfig::default()), (5.0, 100.0));
assert_eq!(
physical(5.0, 100.0, 10.0, dt, SpringConfig::default()),
(5.0, 100.0)
);
}
}
@@ -556,7 +631,11 @@ fn creation_clock_excludes_earlier_idle_time_and_records_all_sample_fields() {
clock.resume(created);
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
let first = clock.sample(created + Duration::from_millis(16), &mut value, SpringConfig::default());
let first = clock.sample(
created + Duration::from_millis(16),
&mut value,
SpringConfig::default(),
);
let exact = reference(0.0, 0.0, 500.0, 0.016, SpringConfig::default());
close(first.current, exact.0, 1e-8);
close(first.velocity, exact.1, 1e-8);
+49 -14
View File
@@ -114,13 +114,19 @@ impl Matcher {
return None;
}
for (rule, regex) in &self.rules {
if parents.iter().any(|parent| rule.ignore_parents.contains(parent)) {
if parents
.iter()
.any(|parent| rule.ignore_parents.contains(parent))
{
continue;
}
if let Some(captures) = regex.captures(command) {
let mut title = rule.title.clone();
for index in 0..captures.len() {
title = title.replace(&format!("{{{index}}}"), captures.get(index).map_or("", |m| m.as_str()));
title = title.replace(
&format!("{{{index}}}"),
captures.get(index).map_or("", |m| m.as_str()),
);
}
if title.len() > 256 || title.chars().any(char::is_control) {
return None;
@@ -149,14 +155,22 @@ fn validate_rule(rule: &Rule, regex: &Regex) -> Result<(), String> {
if rule.pattern.len() > 2_048
|| rule.min_duration_ms > 60_000
|| !matches!(rule.progress.as_str(), "indeterminate" | "cargo-json")
|| rule.dedupe_by.as_deref().is_some_and(|value| value != "cwd")
|| rule
.dedupe_by
.as_deref()
.is_some_and(|value| value != "cwd")
{
return Err("invalid rule duration, progress adapter or dedupe policy".into());
}
if rule.ignore_parents.len() > 16 || rule.ignore_parents.iter().any(|name| name.len() > 128) {
return Err("too many or too long parent names".into());
}
if rule.icon.len() > 64 || !rule.icon.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
if rule.icon.len() > 64
|| !rule
.icon
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Err("invalid symbolic icon".into());
}
let capture = Regex::new(r"\{([^}]+)\}").expect("static pattern");
@@ -201,7 +215,10 @@ impl Settings {
}
fn diagnostic(node: &KdlNode, message: impl std::fmt::Display) -> Error {
Error(format!("activities at byte {}: {message}", node.span().offset()))
Error(format!(
"activities at byte {}: {message}",
node.span().offset()
))
}
pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
@@ -241,7 +258,12 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
}
let key = name.replace('-', "_");
let parsed = if name == "ignore-parents" {
if property.entries().iter().any(|entry| entry.name().is_some()) || property.entries().is_empty() {
if property
.entries()
.iter()
.any(|entry| entry.name().is_some())
|| property.entries().is_empty()
{
return Err(diagnostic(property, "ignore-parents requires names"));
}
Value::Array(
@@ -252,7 +274,8 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
.collect::<Result<_, _>>()?,
)
} else {
crate::node_value(property, &key, "rule").map_err(|error| diagnostic(property, error))?
crate::node_value(property, &key, "rule")
.map_err(|error| diagnostic(property, error))?
};
if value.insert(key, parsed).is_some() {
return Err(diagnostic(property, "duplicate rule setting"));
@@ -268,12 +291,15 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
value
.entry("result")
.or_insert(json!({"ok":"Finished","fail":"Command failed"}));
let rule: Rule = serde_json::from_value(Value::Object(value)).map_err(|error| diagnostic(child, error))?;
let rule: Rule = serde_json::from_value(Value::Object(value))
.map_err(|error| diagnostic(child, error))?;
let settings = Settings {
rules: vec![rule.clone()],
..Default::default()
};
settings.validate().map_err(|error| diagnostic(child, error))?;
settings
.validate()
.map_err(|error| diagnostic(child, error))?;
rules.push(rule);
} else {
if !matches!(
@@ -290,16 +316,24 @@ 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").map_err(|error| diagnostic(child, error))?;
let value = crate::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"));
}
}
}
rules.extend(builtins().into_iter().filter(|rule| !names.contains(&rule.name)));
rules.extend(
builtins()
.into_iter()
.filter(|rule| !names.contains(&rule.name)),
);
values.insert("rules".into(), serde_json::to_value(rules).unwrap());
let settings: Settings = serde_json::from_value(Value::Object(values)).map_err(|error| diagnostic(node, error))?;
settings.validate().map_err(|error| diagnostic(node, error))?;
let settings: Settings =
serde_json::from_value(Value::Object(values)).map_err(|error| diagnostic(node, error))?;
settings
.validate()
.map_err(|error| diagnostic(node, error))?;
Ok(serde_json::to_value(settings).unwrap())
}
fn builtins() -> Vec<Rule> {
@@ -371,7 +405,8 @@ mod tests {
"work-minuts 20",
] {
assert!(
crate::Document::parse(&format!("activities {{ pomodoro {{ {invalid}; }}; }}")).is_err(),
crate::Document::parse(&format!("activities {{ pomodoro {{ {invalid}; }}; }}"))
.is_err(),
"{invalid}"
);
}
+35 -7
View File
@@ -2,12 +2,32 @@ use crate::theme::Appearance;
pub use ferese_theme_model::families::{Family, Palette};
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [
("ferese-blue", "Ferese Blue", "ferese-blue-light", "ferese-blue"),
("catppuccin", "Catppuccin", "catppuccin-latte", "catppuccin-mocha"),
(
"ferese-blue",
"Ferese Blue",
"ferese-blue-light",
"ferese-blue",
),
(
"catppuccin",
"Catppuccin",
"catppuccin-latte",
"catppuccin-mocha",
),
("gruvbox", "Gruvbox", "gruvbox-light", "gruvbox"),
("rose-pine", "Rosé Pine", "rose-pine-dawn", "rose-pine-moon"),
("tokyo-night", "Tokyo Night", "tokyo-night-day", "tokyo-night"),
("everforest", "Everforest", "everforest-light", "everforest-dark"),
(
"tokyo-night",
"Tokyo Night",
"tokyo-night-day",
"tokyo-night",
),
(
"everforest",
"Everforest",
"everforest-light",
"everforest-dark",
),
];
pub fn family_id(preset: &str) -> &str {
@@ -19,7 +39,11 @@ pub fn family_id(preset: &str) -> &str {
pub fn variant(id: &str, appearance: Appearance) -> Option<&'static str> {
if let Some(row) = BUILTINS.iter().find(|row| row.0 == id) {
return Some(if appearance == Appearance::Light { row.2 } else { row.3 });
return Some(if appearance == Appearance::Light {
row.2
} else {
row.3
});
}
match (id, appearance) {
("dracula", Appearance::Dark) => Some("dracula"),
@@ -36,8 +60,12 @@ pub fn builtins() -> Vec<Family> {
.map(|(id, name, light, dark)| Family {
id: (*id).into(),
name: (*name).into(),
light: Some(Palette::from(&crate::theme::preset(light, Appearance::Light).unwrap())),
dark: Some(Palette::from(&crate::theme::preset(dark, Appearance::Dark).unwrap())),
light: Some(Palette::from(
&crate::theme::preset(light, Appearance::Light).unwrap(),
)),
dark: Some(Palette::from(
&crate::theme::preset(dark, Appearance::Dark).unwrap(),
)),
})
.collect()
}
+19 -6
View File
@@ -28,7 +28,10 @@ impl ConfigFile {
.join(path.file_name().ok_or("Invalid configuration path")?),
Err(error) => return Err(error.to_string()),
};
let mut lock_name = path.file_name().ok_or("Invalid configuration path")?.to_os_string();
let mut lock_name = path
.file_name()
.ok_or("Invalid configuration path")?
.to_os_string();
lock_name.push(".lock");
let lock = OpenOptions::new()
@@ -40,7 +43,9 @@ impl ConfigFile {
.open(path.with_file_name(lock_name))
.map_err(|e| e.to_string())?;
lock.try_lock().map_err(|error| match error {
fs::TryLockError::WouldBlock => "Configuration is being saved by another app. Please try again.".into(),
fs::TryLockError::WouldBlock => {
"Configuration is being saved by another app. Please try again.".into()
}
fs::TryLockError::Error(error) => error.to_string(),
})?;
@@ -77,7 +82,9 @@ impl ConfigFile {
.map_err(|e| e.to_string())?;
}
staged.write_all(desired.as_bytes()).map_err(|e| e.to_string())?;
staged
.write_all(desired.as_bytes())
.map_err(|e| e.to_string())?;
staged.as_file().sync_all().map_err(|e| e.to_string())?;
validate(staged.path())?;
@@ -86,8 +93,11 @@ impl ConfigFile {
}
if let Some(path) = backup {
let mut previous = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
previous.write_all(self.source.as_bytes()).map_err(|e| e.to_string())?;
let mut previous =
tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
previous
.write_all(self.source.as_bytes())
.map_err(|e| e.to_string())?;
previous.as_file().sync_all().map_err(|e| e.to_string())?;
previous.persist(path).map_err(|e| e.to_string())?;
}
@@ -187,7 +197,10 @@ mod tests {
let path = directory.path().join("config.kdl");
fs::write(&path, "// initial").unwrap();
let file = ConfigFile::open(&path).unwrap();
assert!(file.replace("// rejected", None, |_| Err("invalid".into())).is_err());
assert!(
file.replace("// rejected", None, |_| Err("invalid".into()))
.is_err()
);
assert_eq!(ConfigFile::open(&path).unwrap().source(), "// initial");
}
}
+171 -52
View File
@@ -66,7 +66,10 @@ pub fn default_wallpaper_for(appearance: theme::Appearance) -> &'static str {
.join(name)
});
path.canonicalize().unwrap_or(path).to_string_lossy().into_owned()
path.canonicalize()
.unwrap_or(path)
.to_string_lossy()
.into_owned()
})
}
@@ -93,7 +96,12 @@ fn is_records(key: &str, parent: &str) -> bool {
(parent, key),
(
"",
"bindings" | "binding_modes" | "window_rules" | "output_profiles" | "autostart" | "panels"
"bindings"
| "binding_modes"
| "window_rules"
| "output_profiles"
| "autostart"
| "panels"
) | ("output_profiles", "outputs")
| ("binding_modes", "bindings")
| ("start" | "center" | "end", "groups")
@@ -105,7 +113,13 @@ fn is_array(key: &str, parent: &str) -> bool {
parent == "commands"
|| matches!(
key,
"command" | "position" | "padding" | "width_presets" | "xkb_options" | "settings_command" | "outputs"
"command"
| "position"
| "padding"
| "width_presets"
| "xkb_options"
| "settings_command"
| "outputs"
)
}
@@ -114,7 +128,9 @@ fn scalar(value: &KdlValue) -> Result<Value, Error> {
KdlValue::String(s) => Value::String(s.clone()),
KdlValue::Bool(v) => Value::Bool(*v),
KdlValue::Null => {
return Err(Error("Omit optional settings instead of using #null".into()));
return Err(Error(
"Omit optional settings instead of using #null".into(),
));
}
KdlValue::Integer(n) => {
if let Ok(n) = i64::try_from(*n) {
@@ -154,7 +170,9 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
node_value(node, &key, parent)?
};
if is_records(&key, parent) {
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
let records = result
.entry(key)
.or_insert_with(|| Value::Array(Vec::new()));
records.as_array_mut().unwrap().push(value);
} else {
insert(&mut result, key, value)?;
@@ -166,7 +184,9 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
if node.ty().is_some() || node.entries().iter().any(|e| e.ty().is_some()) {
return Err(Error("Type annotations are not supported in configuration".into()));
return Err(Error(
"Type annotations are not supported in configuration".into(),
));
}
let args = node
@@ -182,7 +202,11 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
.collect::<Vec<_>>();
if is_records(key, parent) {
let mut map = node.children().map(|d| object(d, key)).transpose()?.unwrap_or_default();
let mut map = node
.children()
.map(|d| object(d, key))
.transpose()?
.unwrap_or_default();
for (name, entry) in props {
insert(&mut map, name, scalar(entry.value())?)?;
}
@@ -219,7 +243,11 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
)));
}
let mut map = node.children().map(|d| object(d, key)).transpose()?.unwrap_or_default();
let mut map = node
.children()
.map(|d| object(d, key))
.transpose()?
.unwrap_or_default();
for (name, entry) in props {
insert(&mut map, name, scalar(entry.value())?)?;
}
@@ -245,7 +273,9 @@ pub struct Document {
impl Document {
pub fn parse(source: &str) -> Result<Self, Error> {
let doc = source.parse::<KdlDocument>().map_err(|e| Error(format!("{e:?}")))?;
let doc = source
.parse::<KdlDocument>()
.map_err(|e| Error(format!("{e:?}")))?;
let value = Value::Object(object(&doc, "")?);
Ok(Self { doc, value })
@@ -258,7 +288,8 @@ impl Document {
/// A runtime view; the original KDL remains available for comment-preserving edits.
pub fn with_theme(&self, theme: &theme::ResolvedTheme) -> Self {
let mut document = self.clone();
document.value["theme"] = serde_json::to_value(&theme.tokens).expect("serializable theme tokens");
document.value["theme"] =
serde_json::to_value(&theme.tokens).expect("serializable theme tokens");
document.value["theme"]["appearance"] = serde_json::to_value(theme.appearance).unwrap();
document
}
@@ -317,7 +348,10 @@ impl Document {
pub fn add(&mut self, path: &str, fields: Vec<(String, Value)>) -> Result<(), Error> {
let mut candidate = self.clone();
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
let parent = parents.rsplit('.').find(|s| s.parse::<usize>().is_err()).unwrap_or("");
let parent = parents
.rsplit('.')
.find(|s| s.parse::<usize>().is_err())
.unwrap_or("");
if !is_records(last, parent) {
return Err(Error("Expected a list of records".into()));
@@ -325,12 +359,18 @@ impl Document {
let doc = section_mut(
&mut candidate.doc,
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
"",
)?;
doc.nodes_mut()
.push(value_node(last, &Value::Object(fields.into_iter().collect()), parent)?);
doc.nodes_mut().push(value_node(
last,
&Value::Object(fields.into_iter().collect()),
parent,
)?);
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
@@ -344,11 +384,21 @@ impl Document {
}
if self.get("theme.light").is_some() || self.get("theme.dark").is_some() {
set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?;
set_in(
&mut self.doc,
&["theme", "mode"],
"",
Value::String("dark".into()),
)?;
return self.refresh();
}
let Some(index) = self.doc.nodes().iter().position(|node| node.name().value() == "theme") else {
let Some(index) = self
.doc
.nodes()
.iter()
.position(|node| node.name().value() == "theme")
else {
return Ok(());
};
@@ -356,7 +406,11 @@ impl Document {
let mut moved = Vec::new();
for name in ["colors", "surface", "border", "focus-ring"] {
if let Some(index) = children.nodes().iter().position(|node| node.name().value() == name) {
if let Some(index) = children
.nodes()
.iter()
.position(|node| node.name().value() == name)
{
moved.push(children.nodes_mut().remove(index));
}
}
@@ -367,7 +421,12 @@ impl Document {
}
}
set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?;
set_in(
&mut self.doc,
&["theme", "mode"],
"",
Value::String("dark".into()),
)?;
self.refresh()
}
@@ -388,14 +447,18 @@ impl Document {
let doc = section_mut(
&mut candidate.doc,
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
"",
)?;
if let Some(index) = doc
.nodes()
.iter()
.position(|node| field(node.name().value(), parents.rsplit('.').next().unwrap_or("")) == name)
{
if let Some(index) = doc.nodes().iter().position(|node| {
field(
node.name().value(),
parents.rsplit('.').next().unwrap_or(""),
) == name
}) {
let node = doc.nodes_mut().remove(index);
let mut comments = String::new();
@@ -423,14 +486,19 @@ impl Document {
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
let doc = section_mut(
&mut candidate.doc,
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
"",
)?;
let i = doc
.nodes()
.iter()
.enumerate()
.filter(|(_, n)| field(n.name().value(), parents.rsplit('.').next().unwrap_or("")) == last)
.filter(|(_, n)| {
field(n.name().value(), parents.rsplit('.').next().unwrap_or("")) == last
})
.nth(index)
.map(|(i, _)| i)
.ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?;
@@ -498,7 +566,11 @@ type NodeFormat = (Vec<Option<kdl::KdlEntryFormat>>, Option<String>);
fn collect_node_formats(doc: &KdlDocument, formats: &mut Vec<NodeFormat>) {
for node in doc.nodes() {
let entries = node.entries().iter().map(|entry| entry.format().cloned()).collect();
let entries = node
.entries()
.iter()
.map(|entry| entry.format().cloned())
.collect();
let terminator = node
.format()
.map(|format| format.terminator.clone())
@@ -543,7 +615,11 @@ fn format_document(doc: &mut KdlDocument) {
}
fn node_index(doc: &mut KdlDocument, key: &str, parent: &str) -> usize {
if let Some(i) = doc.nodes().iter().position(|n| field(n.name().value(), parent) == key) {
if let Some(i) = doc
.nodes()
.iter()
.position(|n| field(n.name().value(), parent) == key)
{
return i;
}
@@ -551,7 +627,11 @@ fn node_index(doc: &mut KdlDocument, key: &str, parent: &str) -> usize {
doc.nodes().len() - 1
}
fn section_mut<'a>(doc: &'a mut KdlDocument, parts: &[&str], parent: &str) -> Result<&'a mut KdlDocument, Error> {
fn section_mut<'a>(
doc: &'a mut KdlDocument,
parts: &[&str],
parent: &str,
) -> Result<&'a mut KdlDocument, Error> {
if parts.is_empty() {
return Ok(doc);
}
@@ -606,10 +686,11 @@ fn unset_property(doc: &mut KdlDocument, parts: &[&str], parent: &str) -> Result
return Ok(false);
};
if parts.len() == used + 1
&& let Some(index) = node
.entries()
.iter()
.position(|entry| entry.name().is_some_and(|name| field(name.value(), key) == parts[used]))
&& let Some(index) = node.entries().iter().position(|entry| {
entry
.name()
.is_some_and(|name| field(name.value(), key) == parts[used])
})
{
let entry = node.entries_mut().remove(index);
if let Some(format) = entry.format() {
@@ -660,7 +741,8 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
{
collect_comments(node, &mut comments);
}
doc.nodes_mut().retain(|node| field(node.name().value(), parent) != key);
doc.nodes_mut()
.retain(|node| field(node.name().value(), parent) != key);
if let Some(first) = nodes.first_mut() {
first.set_format(kdl::KdlNodeFormat {
leading: comments,
@@ -732,7 +814,9 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
}
}
if key == "autostart" && rest[0] == "command" && node.children().is_none_or(|d| d.get("command").is_none())
if key == "autostart"
&& rest[0] == "command"
&& node.children().is_none_or(|d| d.get("command").is_none())
{
node.entries_mut().retain(|e| e.name().is_some());
for value in value
@@ -847,9 +931,15 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
{
node.entries_mut().push(KdlEntry::new(kdl_value(value)?));
}
} else if is_records(key, parent) && map.len() <= 4 && !value.is_array() && !value.is_object() {
node.entries_mut()
.push(KdlEntry::new_prop(name.replace('_', "-"), kdl_value(value)?));
} else if is_records(key, parent)
&& map.len() <= 4
&& !value.is_array()
&& !value.is_object()
{
node.entries_mut().push(KdlEntry::new_prop(
name.replace('_', "-"),
kdl_value(value)?,
));
} else {
append(&mut child, name, value, key)?;
}
@@ -871,7 +961,10 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
fn append(doc: &mut KdlDocument, key: &str, value: &Value, parent: &str) -> Result<(), Error> {
if is_records(key, parent) {
for record in value.as_array().ok_or_else(|| Error("Expected records".into()))? {
for record in value
.as_array()
.ok_or_else(|| Error("Expected records".into()))?
{
doc.nodes_mut().push(value_node(key, record, parent)?);
}
} else {
@@ -894,13 +987,22 @@ mod tests {
"binding-mode resize { binding h resize left; binding l resize right; }\nbinding-mode launch { binding t spawn terminal; }\n",
).unwrap();
assert_eq!(document.get("binding_modes.0.name").unwrap(), "resize");
assert_eq!(document.get("binding_modes.1.bindings.0.argument").unwrap(), "terminal");
assert_eq!(
document.get("binding_modes.1.bindings.0.argument").unwrap(),
"terminal"
);
document
.set("binding_modes.0.bindings.0.argument", "up".into())
.unwrap();
let reparsed = super::Document::parse(&document.to_string()).unwrap();
assert_eq!(reparsed.get("binding_modes.0.bindings.0.argument").unwrap(), "up");
assert_eq!(reparsed.get("binding_modes.0.bindings.1.argument").unwrap(), "right");
assert_eq!(
reparsed.get("binding_modes.0.bindings.0.argument").unwrap(),
"up"
);
assert_eq!(
reparsed.get("binding_modes.0.bindings.1.argument").unwrap(),
"right"
);
}
#[test]
fn adopting_appearance_modes_preserves_legacy_colors_and_comments() {
@@ -908,7 +1010,9 @@ mod tests {
"// my desktop\ntheme {\n colors {\n accent \"#123456\" // my accent\n }\n}\n",
)
.unwrap();
document.set("theme.mode", serde_json::json!("light")).unwrap();
document
.set("theme.mode", serde_json::json!("light"))
.unwrap();
assert_eq!(
document.get("theme.dark.colors.accent"),
Some(&serde_json::json!("#123456"))
@@ -931,7 +1035,8 @@ mod tests {
#[test]
fn inline_comments_and_failed_edits_leave_document_intact() {
let mut doc = Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap();
let mut doc =
Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap();
doc.set("animations.speed", 0.8.into()).unwrap();
assert!(doc.to_string().contains("// keep inline"));
assert_eq!(
@@ -974,11 +1079,15 @@ autostart "program" "two words" enabled=#false
assert_eq!(doc.get("bindings.0.action").unwrap(), "toggle-overview");
doc.set("bindings.0.action", "none".into()).unwrap();
doc.set("input.repeat_rate", 25.into()).unwrap();
doc.set("output_profiles.0.outputs.0.scale", 2.0.into()).unwrap();
doc.set("output_profiles.0.outputs.0.scale", 2.0.into())
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(reparsed.get("bindings.0.action").unwrap(), "none");
assert_eq!(reparsed.get("input.repeat_rate").unwrap(), 25);
assert_eq!(reparsed.get("output_profiles.0.outputs.0.scale").unwrap(), 2.0);
assert_eq!(
reparsed.get("output_profiles.0.outputs.0.scale").unwrap(),
2.0
);
}
#[test]
@@ -987,13 +1096,21 @@ autostart "program" "two words" enabled=#false
"output-profile desk { output match=\"DP-1\" { position 0 0; }; }\ncommands { lock-screen \"locker\"; }\n",
)
.unwrap();
doc.set("output_profiles.0.outputs.0.position", serde_json::json!([100, 200]))
.unwrap();
doc.set("commands.lock-screen", serde_json::json!(["ferese-lock", "--lock"]))
.unwrap();
doc.set(
"output_profiles.0.outputs.0.position",
serde_json::json!([100, 200]),
)
.unwrap();
doc.set(
"commands.lock-screen",
serde_json::json!(["ferese-lock", "--lock"]),
)
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("output_profiles.0.outputs.0.position").unwrap(),
reparsed
.get("output_profiles.0.outputs.0.position")
.unwrap(),
&serde_json::json!([100, 200])
);
assert_eq!(
@@ -1023,7 +1140,9 @@ autostart "program" "two words" enabled=#false
serde_json::json!({})
);
}
assert!(Document::parse("panel main { metadata { desktop-widgets \"custom\"; }; }").is_ok());
assert!(
Document::parse("panel main { metadata { desktop-widgets \"custom\"; }; }").is_ok()
);
}
#[test]
+304 -105
View File
@@ -78,17 +78,18 @@ impl Schedule {
AutoSource::System => Ok((
context.system.unwrap_or(Appearance::Dark),
None,
context
.system
.is_none()
.then(|| "System appearance is unavailable. Using Dark until it becomes available.".into()),
context.system.is_none().then(|| {
"System appearance is unavailable. Using Dark until it becomes available."
.into()
}),
)),
AutoSource::Schedule => self
.boundaries(now, zone)
.map(|(appearance, next)| (appearance, Some(next), None)),
AutoSource::SunriseSunset => {
Err("Sunrise/sunset is not available yet; choose Follow system or Custom schedule.".into())
}
AutoSource::SunriseSunset => Err(
"Sunrise/sunset is not available yet; choose Follow system or Custom schedule."
.into(),
),
}
}
@@ -105,7 +106,11 @@ impl Schedule {
}
}
pub fn boundaries(&self, now: Timestamp, zone: &TimeZone) -> Result<(Appearance, Timestamp), String> {
pub fn boundaries(
&self,
now: Timestamp,
zone: &TimeZone,
) -> Result<(Appearance, Timestamp), String> {
let parse = |s: &str| -> Result<Time, String> {
if s.len() != 5 || s.as_bytes()[2] != b':' {
return Err("theme schedule times must use HH:MM".into());
@@ -171,7 +176,9 @@ pub struct Policy {
pub accent: Option<String>,
}
fn deserialize_light_selection<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Selection, D::Error> {
fn deserialize_light_selection<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Selection, D::Error> {
let mut value = Value::deserialize(deserializer)?;
if let Some(object) = value.as_object_mut() {
object.entry("preset").or_insert(json!("ferese-blue-light"));
@@ -264,11 +271,12 @@ pub fn resolve_with_context(
} else {
TimeZone::UTC
};
let (scheduled, next, auto_note) = if policy.mode == Mode::Auto || policy.schedule.source == AutoSource::Schedule {
policy.schedule.resolve(now, &zone, context)?
} else {
(Appearance::Dark, None, None)
};
let (scheduled, next, auto_note) =
if policy.mode == Mode::Auto || policy.schedule.source == AutoSource::Schedule {
policy.schedule.resolve(now, &zone, context)?
} else {
(Appearance::Dark, None, None)
};
let appearance = match policy.mode {
Mode::Light => Appearance::Light,
Mode::Dark => Appearance::Dark,
@@ -300,14 +308,22 @@ pub fn resolve_with_context(
imported.insert(id.clone(), variants);
}
let split = policy.split.unwrap_or_else(|| {
crate::families::family_id(&policy.light.preset) != crate::families::family_id(&policy.dark.preset)
crate::families::family_id(&policy.light.preset)
!= crate::families::family_id(&policy.dark.preset)
});
let single = policy
.family
.as_deref()
.unwrap_or_else(|| crate::families::family_id(&policy.dark.preset));
let mut fallback_note = if policy.mode == Mode::Auto { auto_note } else { None };
for (kind, selection) in [(Appearance::Light, &policy.light), (Appearance::Dark, &policy.dark)] {
let mut fallback_note = if policy.mode == Mode::Auto {
auto_note
} else {
None
};
for (kind, selection) in [
(Appearance::Light, &policy.light),
(Appearance::Dark, &policy.dark),
] {
let family_id = if split {
selection
.family
@@ -316,15 +332,16 @@ pub fn resolve_with_context(
} else {
single
};
let variant = imported
.get(family_id)
.and_then(|pair: &(Option<Tokens>, Option<Tokens>)| {
if kind == Appearance::Light {
pair.0.clone()
} else {
pair.1.clone()
}
});
let variant =
imported
.get(family_id)
.and_then(|pair: &(Option<Tokens>, Option<Tokens>)| {
if kind == Appearance::Light {
pair.0.clone()
} else {
pair.1.clone()
}
});
let custom_variant = variant.is_some();
let mut tokens = if let Some(tokens) = variant {
tokens
@@ -332,7 +349,10 @@ pub fn resolve_with_context(
preset(id, kind)?
} else {
if !imported.contains_key(family_id)
&& !matches!(family_id, "dracula" | "monochrome" | "monokai" | "ayu-light")
&& !matches!(
family_id,
"dracula" | "monochrome" | "monokai" | "ayu-light"
)
{
return Err(format!("Unknown theme family: {family_id}"));
}
@@ -343,7 +363,11 @@ pub fn resolve_with_context(
.map_or(family_id, |family| family.name.as_str());
fallback_note = Some(format!(
"{name} has no {} variant. Using Ferese Blue for this appearance.",
if kind == Appearance::Light { "light" } else { "dark" }
if kind == Appearance::Light {
"light"
} else {
"dark"
}
));
}
preset(
@@ -358,8 +382,8 @@ pub fn resolve_with_context(
if !families.iter().any(|family| family.id == family_id) {
let light = crate::families::variant(family_id, Appearance::Light)
.and_then(|id| preset(id, Appearance::Light).ok());
let dark =
crate::families::variant(family_id, Appearance::Dark).and_then(|id| preset(id, Appearance::Dark).ok());
let dark = crate::families::variant(family_id, Appearance::Dark)
.and_then(|id| preset(id, Appearance::Dark).ok());
families.push(crate::families::Family {
id: family_id.into(),
name: family_id.into(),
@@ -372,12 +396,16 @@ pub fn resolve_with_context(
let authored_accent = tokens.colors.accent.clone();
let preset_end = (!custom_variant)
.then(|| {
let id = crate::families::variant(family_id, kind).unwrap_or(if kind == Appearance::Light {
"ferese-blue-light"
} else {
"ferese-blue"
});
crate::presets::PRESETS.iter().find(|preset| preset.id == id)
let id = crate::families::variant(family_id, kind).unwrap_or(
if kind == Appearance::Light {
"ferese-blue-light"
} else {
"ferese-blue"
},
);
crate::presets::PRESETS
.iter()
.find(|preset| preset.id == id)
})
.flatten()
.map(|preset| preset.gradient_end)
@@ -402,7 +430,11 @@ pub fn resolve_with_context(
let layer = Document::parse(&source).map_err(|e| format!("{}: {e}", path.display()))?;
let layer = layer.get("theme").unwrap_or(layer.value());
for role in ["surface_raised", "application_background"] {
if layer.get("colors").and_then(|colors| colors.get(role)).is_some() {
if layer
.get("colors")
.and_then(|colors| colors.get(role))
.is_some()
{
explicit_surfaces.insert(role);
}
}
@@ -419,7 +451,11 @@ pub fn resolve_with_context(
tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
for layer in [&root, &Value::Object(overrides.clone())] {
for role in ["surface_raised", "application_background"] {
if layer.get("colors").and_then(|colors| colors.get(role)).is_some() {
if layer
.get("colors")
.and_then(|colors| colors.get(role))
.is_some()
{
explicit_surfaces.insert(role);
}
}
@@ -430,25 +466,35 @@ pub fn resolve_with_context(
} else {
[1.; 4]
};
if tokens.colors.surface_base != authored_base && !explicit_surfaces.contains("surface_raised") {
if tokens.colors.surface_base != authored_base
&& !explicit_surfaces.contains("surface_raised")
{
tokens.colors.surface_raised = hex(blend(base, shade, 0.055));
}
if tokens.colors.surface_base != authored_base && !explicit_surfaces.contains("application_background") {
if tokens.colors.surface_base != authored_base
&& !explicit_surfaces.contains("application_background")
{
tokens.colors.application_background = hex(blend(base, shade, 0.025));
}
if let Some(accent) = &policy.accent {
tokens.colors.accent = accent.clone();
}
for path in [&mut tokens.background.path, &mut tokens.background.lock_path]
.into_iter()
.flatten()
for path in [
&mut tokens.background.path,
&mut tokens.background.lock_path,
]
.into_iter()
.flatten()
{
*path = theme_path(directory, path);
}
validate(&tokens)?;
let requested_accent = tokens.colors.accent.clone();
transform(&mut tokens, &policy.accessibility, &mut warnings);
resolve_gradients(&mut tokens, preset_end.filter(|_| requested_accent == authored_accent));
resolve_gradients(
&mut tokens,
preset_end.filter(|_| requested_accent == authored_accent),
);
let theme = ResolvedTheme {
appearance: kind,
tokens,
@@ -471,7 +517,11 @@ pub fn resolve_with_context(
theme: selected.expect("selected appearance"),
warnings,
files: all_files,
next_transition: if policy.mode == Mode::Auto { next } else { None },
next_transition: if policy.mode == Mode::Auto {
next
} else {
None
},
families,
fallback_note,
})
@@ -513,7 +563,12 @@ pub fn validate_overrides(document: &Document) -> Result<(), String> {
let mut tokens = token_value(&default_tokens())?;
merge(&mut tokens, root, "", true)?;
for selection in [&policy.light, &policy.dark] {
merge(&mut tokens, &Value::Object(selection.overrides.clone()), "", false)?;
merge(
&mut tokens,
&Value::Object(selection.overrides.clone()),
"",
false,
)?;
}
Ok(())
}
@@ -562,7 +617,10 @@ fn merge(target: &mut Value, layer: &Value, prefix: &str, inline: bool) -> Resul
}
pub fn preset(name: &str, appearance: Appearance) -> Result<Tokens, String> {
if let Some(preset) = crate::presets::PRESETS.iter().find(|preset| preset.id == name) {
if let Some(preset) = crate::presets::PRESETS
.iter()
.find(|preset| preset.id == name)
{
if preset.appearance != appearance {
return Err(format!("Preset {name} does not support {appearance:?}"));
}
@@ -640,7 +698,10 @@ pub fn authored_warnings(tokens: &Tokens) -> Vec<String> {
let surfaces = [
("surface-base", &tokens.colors.surface_base),
("surface-raised", &tokens.colors.surface_raised),
("application-background", &tokens.colors.application_background),
(
"application-background",
&tokens.colors.application_background,
),
];
let check = |foreground: &str, background: &str, minimum: f64| {
let foreground = rgba(foreground).unwrap();
@@ -685,7 +746,9 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
}
Value::Number(n) => {
let n = n.as_f64().ok_or("invalid theme number")?;
if !n.is_finite() || (path != ".shadow.soft.offset_y" && !path.ends_with(".angle") && n < 0.) {
if !n.is_finite()
|| (path != ".shadow.soft.offset_y" && !path.ends_with(".angle") && n < 0.)
{
return Err(format!("Invalid theme value {path}"));
}
if (path.ends_with("opacity") || path.ends_with("tint_strength")) && n > 1. {
@@ -735,7 +798,9 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
.fold(f64::INFINITY, f64::min)
};
let choose = |preserve_foreground: bool| {
let acceptable = |color| score(color) >= 3. && (!preserve_foreground || contrast(preferred, color) >= 4.5);
let acceptable = |color| {
score(color) >= 3. && (!preserve_foreground || contrast(preferred, color) >= 4.5)
};
if acceptable(requested) {
return Some(requested);
}
@@ -759,7 +824,11 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
});
t.colors.accent = hex(adjusted);
t.colors.on_accent = hex(readable(preferred, adjusted, 4.5));
let minimum = if accessibility.increase_contrast { 7. } else { 4.5 };
let minimum = if accessibility.increase_contrast {
7.
} else {
4.5
};
let body = [surface, backgrounds[2], backgrounds[3]];
let bar = [backgrounds[1]; 3];
for (label, color, backgrounds) in [
@@ -784,7 +853,11 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
}
}
if accessibility.increase_contrast {
t.colors.border = hex(readable_across(rgba(&t.colors.border).unwrap(), &backgrounds, 3.));
t.colors.border = hex(readable_across(
rgba(&t.colors.border).unwrap(),
&backgrounds,
3.,
));
if backgrounds
.iter()
.any(|background| contrast(rgba(&t.colors.border).unwrap(), *background) < 3.)
@@ -818,7 +891,12 @@ fn resolve_gradients(tokens: &mut Tokens, preset_end: Option<&str>) {
rgba(&tokens.surface.bar.background).unwrap(),
];
for (paint, color, endpoint, focus) in [
(&mut tokens.focus_ring, &tokens.colors.accent, preset_end, true),
(
&mut tokens.focus_ring,
&tokens.colors.accent,
preset_end,
true,
),
(&mut tokens.border, &tokens.colors.border, None, false),
] {
if paint.style == PaintStyle::Solid {
@@ -865,7 +943,8 @@ fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
.map(|step| initial + (1. - initial) * f64::from(step) / 100.)
.find(|opacity| {
let bounds = material_bounds(surface, *opacity);
readable(&[bounds[0], bounds[1], raised, app]) && readable(&material_bounds(bar, *opacity))
readable(&[bounds[0], bounds[1], raised, app])
&& readable(&material_bounds(bar, *opacity))
})
.unwrap_or(1.0);
tokens.material.opacity = tokens.material.opacity.max(opacity);
@@ -883,12 +962,20 @@ fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
}
}
pub type ImportedFamily = (crate::families::Family, (Option<Tokens>, Option<Tokens>), Vec<String>);
pub type ImportedFamily = (
crate::families::Family,
(Option<Tokens>, Option<Tokens>),
Vec<String>,
);
pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
let document = Document::parse(source).map_err(|e| e.to_string())?;
let root = document.get("theme").unwrap_or(document.value());
let name = root.get("name").and_then(Value::as_str).unwrap_or(id).to_owned();
let name = root
.get("name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_owned();
let mut variants = [None, None];
let mut warnings = vec![];
for (index, (key, appearance)) in [("light", Appearance::Light), ("dark", Appearance::Dark)]
@@ -907,7 +994,11 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
let mut value = token_value(&base)?;
merge(&mut value, layer, "", false)?;
let mut tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
if layer.get("colors").and_then(|colors| colors.get("border")).is_none() {
if layer
.get("colors")
.and_then(|colors| colors.get("border"))
.is_none()
{
// A new family without an authored border follows its own foreground,
// rather than inheriting the unrelated Ferese Blue border.
let mut border = rgba(&tokens.colors.text_primary)?;
@@ -920,9 +1011,16 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
}
}
if variants.iter().all(Option::is_none) {
return Err("A theme file must declare a light and/or dark section; partial palettes are allowed.".into());
return Err(
"A theme file must declare a light and/or dark section; partial palettes are allowed."
.into(),
);
}
for key in root.as_object().ok_or("Theme file must be a section")?.keys() {
for key in root
.as_object()
.ok_or("Theme file must be a section")?
.keys()
{
if !matches!(key.as_str(), "name" | "light" | "dark") {
return Err(format!("Unknown theme token: {key}"));
}
@@ -946,9 +1044,12 @@ mod tests {
"theme {{ geometry {{ shell-radius {requested}; window-radius 7.25; }} }}"
))
.unwrap();
let result = super::resolve(&document, std::path::Path::new("/tmp"), jiff::Timestamp::now(), |_| {
Err("unexpected theme file".into())
})
let result = super::resolve(
&document,
std::path::Path::new("/tmp"),
jiff::Timestamp::now(),
|_| Err("unexpected theme file".into()),
)
.unwrap();
assert_eq!(result.theme.tokens.geometry.shell_radius, expected);
assert_eq!(result.theme.tokens.geometry.window_radius, 7.25);
@@ -1047,7 +1148,11 @@ mod tests {
];
assert_eq!(
gradient.to,
hex(readable_across(rgba(preset.gradient_end).unwrap(), &backgrounds, 3.))
hex(readable_across(
rgba(preset.gradient_end).unwrap(),
&backgrounds,
3.
))
);
assert!(
backgrounds
@@ -1078,8 +1183,10 @@ mod tests {
assert!(from[0] > from[1] && from[1] > from[2]);
assert!(to[0] > to[1] && to[1] > to[2]);
let document =
Document::parse("theme { family custom; custom-themes { custom { file \"custom.kdl\"; }; }; }").unwrap();
let document = Document::parse(
"theme { family custom; custom-themes { custom { file \"custom.kdl\"; }; }; }",
)
.unwrap();
let theme = resolve(
&document,
Path::new("/config"),
@@ -1118,12 +1225,18 @@ mod tests {
);
assert!(theme.tokens.border.gradient.is_none());
let source = source.replace("family catppuccin", "family catppuccin\nfocus-ring { style solid; }");
let source = source.replace(
"family catppuccin",
"family catppuccin\nfocus-ring { style solid; }",
);
let theme = candidate(&source).unwrap().theme;
assert!(theme.tokens.focus_ring.gradient.is_none());
assert_eq!(theme.tokens.focus_ring.style, PaintStyle::Solid);
assert!(candidate("theme { focus-ring { style rainbow; }; }").is_err());
assert!(candidate("theme { focus-ring { gradient { from \"bad\"; to \"#FFFFFF\"; }; }; }").is_err());
assert!(
candidate("theme { focus-ring { gradient { from \"bad\"; to \"#FFFFFF\"; }; }; }")
.is_err()
);
}
#[test]
@@ -1153,7 +1266,13 @@ mod tests {
assert_eq!(middle.tokens.focus_ring.style, PaintStyle::Auto);
assert!(middle.tokens.focus_ring.gradient.is_some());
assert_eq!(from.transition(&to, 1.), to);
assert!(to.transition(&from, 0.5).tokens.focus_ring.gradient.is_some());
assert!(
to.transition(&from, 0.5)
.tokens
.focus_ring
.gradient
.is_some()
);
}
#[test]
@@ -1168,20 +1287,39 @@ mod tests {
authored_warnings(&tokens)
);
let source = match entry.appearance {
Appearance::Light => format!("theme {{ mode \"light\"; light {{ preset \"{}\"; }}; }}", entry.id),
Appearance::Dark => format!("theme {{ mode \"dark\"; dark {{ preset \"{}\"; }}; }}", entry.id),
Appearance::Light => format!(
"theme {{ mode \"light\"; light {{ preset \"{}\"; }}; }}",
entry.id
),
Appearance::Dark => format!(
"theme {{ mode \"dark\"; dark {{ preset \"{}\"; }}; }}",
entry.id
),
};
let resolved = candidate(&source).unwrap().theme;
assert_eq!(resolved.tokens.colors.border, tokens.colors.border);
assert_eq!(resolved.tokens.colors.surface_raised, tokens.colors.surface_raised);
assert_eq!(
resolved.tokens.colors.surface_raised,
tokens.colors.surface_raised
);
assert_eq!(
resolved.tokens.colors.application_background,
tokens.colors.application_background
);
assert!(authored_warnings(&resolved.tokens).is_empty(), "{}", entry.id);
assert!(
authored_warnings(&resolved.tokens).is_empty(),
"{}",
entry.id
);
}
let dark: Vec<_> = PRESETS.iter().filter(|p| p.appearance == Appearance::Dark).collect();
let light: Vec<_> = PRESETS.iter().filter(|p| p.appearance == Appearance::Light).collect();
let dark: Vec<_> = PRESETS
.iter()
.filter(|p| p.appearance == Appearance::Dark)
.collect();
let light: Vec<_> = PRESETS
.iter()
.filter(|p| p.appearance == Appearance::Light)
.collect();
assert_eq!(dark.len(), 6);
assert_eq!(light.len(), 6);
for (dark, light) in dark.iter().zip(light) {
@@ -1209,7 +1347,12 @@ mod tests {
entry.id
);
let result = candidate(&source).unwrap();
assert!(result.warnings.is_empty(), "{}: {:?}", entry.id, result.warnings);
assert!(
result.warnings.is_empty(),
"{}: {:?}",
entry.id,
result.warnings
);
let theme = result.theme;
assert!(authored_warnings(&theme.tokens).is_empty(), "{}", entry.id);
if increase {
@@ -1218,9 +1361,15 @@ mod tests {
&theme.tokens.colors.surface_raised,
&theme.tokens.colors.application_background,
] {
for foreground in [&theme.tokens.colors.text_primary, &theme.tokens.colors.text_muted] {
for foreground in [
&theme.tokens.colors.text_primary,
&theme.tokens.colors.text_muted,
] {
assert!(
contrast(rgba(foreground).unwrap(), rgba(background).unwrap()) >= 7.,
contrast(
rgba(foreground).unwrap(),
rgba(background).unwrap()
) >= 7.,
"{}: {foreground} on {background}",
entry.id
);
@@ -1262,13 +1411,17 @@ mod tests {
let resolved = candidate(r#"theme { mode "light"; family "catppuccin"; split #true; light { family "gruvbox"; }; dark { family "everforest"; }; }"#).unwrap();
assert_eq!(
resolved.theme.tokens.colors.surface_base,
preset("gruvbox-light", Appearance::Light).unwrap().colors.surface_base
preset("gruvbox-light", Appearance::Light)
.unwrap()
.colors
.surface_base
);
}
#[test]
fn single_variant_families_use_an_explicit_default_fallback() {
let resolved = candidate(r#"theme { mode "light"; family "dracula"; split #false; }"#).unwrap();
let resolved =
candidate(r#"theme { mode "light"; family "dracula"; split #false; }"#).unwrap();
assert_eq!(
resolved.theme.tokens.colors.surface_base,
preset("ferese-blue-light", Appearance::Light)
@@ -1283,7 +1436,8 @@ mod tests {
.iter()
.any(|family| family.id == "dracula" && family.light.is_none())
);
let resolved = candidate(r#"theme { mode "dark"; family "dracula"; split #false; }"#).unwrap();
let resolved =
candidate(r#"theme { mode "dark"; family "dracula"; split #false; }"#).unwrap();
assert!(resolved.fallback_note.is_none());
assert_eq!(resolved.theme.tokens.colors.surface_base, "#282A36");
}
@@ -1376,7 +1530,8 @@ mod tests {
#[test]
fn system_auto_is_context_driven_and_has_no_polling_deadline() {
let document = Document::parse(r#"theme { mode "auto"; schedule { source "system"; }; }"#).unwrap();
let document =
Document::parse(r#"theme { mode "auto"; schedule { source "system"; }; }"#).unwrap();
for appearance in [Appearance::Light, Appearance::Dark] {
let resolved = resolve_with_context(
&document,
@@ -1392,7 +1547,13 @@ mod tests {
assert!(resolved.next_transition.is_none());
assert!(resolved.fallback_note.is_none());
}
let unavailable = resolve(&document, Path::new("/tmp"), Timestamp::now(), |_| unreachable!()).unwrap();
let unavailable = resolve(
&document,
Path::new("/tmp"),
Timestamp::now(),
|_| unreachable!(),
)
.unwrap();
assert!(unavailable.fallback_note.unwrap().contains("unavailable"));
}
@@ -1446,7 +1607,11 @@ mod tests {
light_at: "01:30".into(),
..schedule
};
for now in ["2026-11-01T05:30:00Z", "2026-11-01T06:00:00Z", "2026-11-01T06:30:00Z"] {
for now in [
"2026-11-01T05:30:00Z",
"2026-11-01T06:00:00Z",
"2026-11-01T06:30:00Z",
] {
let (appearance, next) = schedule.boundaries(now.parse().unwrap(), &zone).unwrap();
assert_eq!(appearance, Appearance::Light);
assert_eq!(next, "2026-11-02T00:00:00Z".parse::<Timestamp>().unwrap());
@@ -1459,15 +1624,20 @@ mod tests {
"theme { file \"themes/shared.kdl\"; light { file \"themes/light.kdl\"; }; mode \"light\"; }",
)
.unwrap();
let result = resolve(&doc, Path::new("/config/ferese"), Timestamp::now(), |path| {
match path.to_str().unwrap() {
let result = resolve(
&doc,
Path::new("/config/ferese"),
Timestamp::now(),
|path| match path.to_str().unwrap() {
"/config/ferese/themes/shared.kdl" => {
Ok("colors { accent \"#12AABB\"; text-primary \"#182234\"; }".into())
}
"/config/ferese/themes/light.kdl" => Ok("theme { colors { accent \"#315090\"; }; }".into()),
"/config/ferese/themes/light.kdl" => {
Ok("theme { colors { accent \"#315090\"; }; }".into())
}
_ => panic!("wrong relative path"),
}
})
},
)
.unwrap();
assert_eq!(result.theme.requested_accent, "#315090");
assert_eq!(result.theme.tokens.colors.text_primary, "#182234");
@@ -1516,10 +1686,13 @@ mod tests {
.contains("future_token")
);
assert!(
import_family("bad", "theme { light { geometry { top-bar-radius 8; }; }; }")
.err()
.unwrap()
.contains("geometry.top_bar_radius")
import_family(
"bad",
"theme { light { geometry { top-bar-radius 8; }; }; }"
)
.err()
.unwrap()
.contains("geometry.top_bar_radius")
);
assert!(
import_family("bad", "theme { name Test; future-token 1; light {}; }")
@@ -1549,7 +1722,9 @@ mod tests {
) >= 4.5
);
if increase {
assert!(contrast(rgba(&tokens.colors.text_primary).unwrap(), surface) >= 7.);
assert!(
contrast(rgba(&tokens.colors.text_primary).unwrap(), surface) >= 7.
);
}
if reduce {
assert_eq!(tokens.material.style, "solid");
@@ -1565,8 +1740,12 @@ mod tests {
#[test]
fn transition_text_remains_readable_at_fixed_steps_on_composited_surfaces() {
use crate::presets::PRESETS;
let darks = PRESETS.iter().filter(|entry| entry.appearance == Appearance::Dark);
let lights = PRESETS.iter().filter(|entry| entry.appearance == Appearance::Light);
let darks = PRESETS
.iter()
.filter(|entry| entry.appearance == Appearance::Dark);
let lights = PRESETS
.iter()
.filter(|entry| entry.appearance == Appearance::Light);
for (dark_entry, light_entry) in darks.zip(lights) {
let dark = candidate(&format!(
r#"theme {{ mode "dark"; dark {{ preset "{}"; }}; material {{ style "translucent"; }}; }}"#,
@@ -1585,9 +1764,13 @@ mod tests {
for step in 1..20 {
let frame = from.transition(to, f64::from(step) / 20.);
let mut surface = rgba(&frame.tokens.colors.surface_base).unwrap();
surface[3] = frame.tokens.material.opacity * frame.tokens.material.tint_strength;
surface[3] =
frame.tokens.material.opacity * frame.tokens.material.tint_strength;
let surface = composite(surface, backdrop);
for text in [&frame.tokens.colors.text_primary, &frame.tokens.colors.text_muted] {
for text in [
&frame.tokens.colors.text_primary,
&frame.tokens.colors.text_muted,
] {
assert!(
contrast(rgba(text).unwrap(), surface) >= 4.5,
"step {step}: {text}, contrast {}",
@@ -1598,7 +1781,10 @@ mod tests {
&frame.tokens.colors.surface_raised,
&frame.tokens.colors.application_background,
] {
for text in [&frame.tokens.colors.text_primary, &frame.tokens.colors.text_muted] {
for text in [
&frame.tokens.colors.text_primary,
&frame.tokens.colors.text_muted,
] {
assert!(
contrast(rgba(text).unwrap(), rgba(background).unwrap()) >= 4.5,
"native app step {step}: {text} on {background}"
@@ -1606,13 +1792,17 @@ mod tests {
}
}
let mut bar = rgba(&frame.tokens.surface.bar.background).unwrap();
bar[3] = frame.tokens.material.opacity * frame.tokens.material.tint_strength;
bar[3] =
frame.tokens.material.opacity * frame.tokens.material.tint_strength;
let bar = composite(bar, backdrop);
for text in [
&frame.tokens.surface.bar.text_primary,
&frame.tokens.surface.bar.text_muted,
] {
assert!(contrast(rgba(text).unwrap(), bar) >= 4.5, "bar step {step}: {text}");
assert!(
contrast(rgba(text).unwrap(), bar) >= 4.5,
"bar step {step}: {text}"
);
}
assert!(
contrast(
@@ -1670,8 +1860,16 @@ mod tests {
assert_eq!(from.transition(&from, p), from);
let frame = from.transition(&to, p);
assert_eq!(frame.tokens.material.opacity, from.tokens.material.opacity);
let low = from.tokens.material.tint_strength.min(to.tokens.material.tint_strength);
let high = from.tokens.material.tint_strength.max(to.tokens.material.tint_strength);
let low = from
.tokens
.material
.tint_strength
.min(to.tokens.material.tint_strength);
let high = from
.tokens
.material
.tint_strength
.max(to.tokens.material.tint_strength);
assert!((low..=high).contains(&frame.tokens.material.tint_strength));
}
}
@@ -1692,9 +1890,10 @@ mod tests {
#[test]
fn reduced_motion_cuts_and_policy_does_not_follow_brightness() {
let mut light = candidate("theme { mode \"light\"; colors { surface-base \"#111111\"; }; }")
.unwrap()
.theme;
let mut light =
candidate("theme { mode \"light\"; colors { surface-base \"#111111\"; }; }")
.unwrap()
.theme;
assert_eq!(light.appearance, Appearance::Light);
light.reduced_motion = true;
assert_eq!(default_theme().transition(&light, 0.), light);
+69 -26
View File
@@ -10,7 +10,8 @@ fn removed_panel_surface_fields_are_rejected() {
"style continuous;",
"style islands;",
] {
let document = ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
let document =
ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
assert!(
serde_json::from_value::<Vec<Panel>>(document.get("panels").unwrap().clone()).is_err(),
"{source}"
@@ -26,9 +27,11 @@ fn panel_surfaces_round_trip_without_group_overrides() {
let panel = &panels[0];
assert_eq!(panel.group_surface(), GroupSurface::Island);
assert_eq!(panel.preset(), Some(PanelStyle::Floating));
doc.set("panels", serde_json::to_value(&panels).unwrap()).unwrap();
doc.set("panels", serde_json::to_value(&panels).unwrap())
.unwrap();
let restored = ferese_config::Document::parse(&doc.to_string()).unwrap();
let restored: Vec<Panel> = serde_json::from_value(restored.get("panels").unwrap().clone()).unwrap();
let restored: Vec<Panel> =
serde_json::from_value(restored.get("panels").unwrap().clone()).unwrap();
assert_eq!(restored, panels);
assert!(doc.to_string().contains("// keep"));
let mut preset = panel.clone();
@@ -77,7 +80,10 @@ animations { speed 0.8; }
validate(&panels).unwrap();
assert_eq!(panels[0].end.groups[0].padding, [2, 3]);
assert!(panels[0].background_opacity.is_none());
assert_eq!(panels[0].end.groups[0].items[0].overflow, OverflowPolicy::Always);
assert_eq!(
panels[0].end.groups[0].items[0].overflow,
OverflowPolicy::Always
);
doc.set("panels.0.end.groups.0.items.0.overflow", "never".into())
.unwrap();
let path = "panels.0.end.groups.0.items";
@@ -102,13 +108,18 @@ animations { speed 0.8; }
panels[0].item(&ItemId("network".into())).unwrap().overflow,
OverflowPolicy::Never
);
assert_eq!(parsed.get("animations.speed").unwrap(), &serde_json::json!(0.8));
assert_eq!(
parsed.get("animations.speed").unwrap(),
&serde_json::json!(0.8)
);
let value = serde_json::to_value(&panels).unwrap();
doc.set("panels", value.clone()).unwrap();
let round_trip = ferese_config::Document::parse(&doc.to_string()).unwrap();
let restored: Vec<Panel> = serde_json::from_value(round_trip.get("panels").unwrap().clone()).unwrap();
let restored: Vec<Panel> =
serde_json::from_value(round_trip.get("panels").unwrap().clone()).unwrap();
assert_eq!(restored, panels);
doc.set(path, Vec::<serde_json::Value>::new().into()).unwrap();
doc.set(path, Vec::<serde_json::Value>::new().into())
.unwrap();
assert!(doc.to_string().contains("// network comment"));
let restored: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
assert!(restored[0].end.groups[0].items.is_empty());
@@ -117,8 +128,10 @@ animations { speed 0.8; }
#[test]
fn panel_border_defaults_off_and_round_trips_an_explicit_enabled_value() {
let mut doc =
ferese_config::Document::parse("panel main { end { group status { item clock kind=clock; }; }; }\n").unwrap();
let mut doc = ferese_config::Document::parse(
"panel main { end { group status { item clock kind=clock; }; }; }\n",
)
.unwrap();
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
assert!(!panels[0].border);
doc.set("panels.0.border", true.into()).unwrap();
@@ -131,7 +144,8 @@ fn panel_border_defaults_off_and_round_trips_an_explicit_enabled_value() {
#[test]
fn panel_background_opacity_inherits_until_overridden_and_validates_bounds() {
let doc = ferese_config::Document::parse("panel main { end { group status { }; }; }").unwrap();
let mut panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
let mut panels: Vec<Panel> =
serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
assert_eq!(panels[0].background_opacity, None);
assert!(
serde_json::to_value(&panels[0]).unwrap()["end"]["groups"][0]
@@ -155,14 +169,21 @@ fn panel_edge_defaults_to_top_and_round_trips_both_positions() {
("edge top;", Edge::Top),
("edge bottom;", Edge::Bottom),
] {
let document = ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
let panels: Vec<Panel> = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
let document =
ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
let panels: Vec<Panel> =
serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
validate(&panels).unwrap();
assert_eq!(panels[0].edge, expected);
let restored: Vec<Panel> = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
let restored: Vec<Panel> =
serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
assert_eq!(restored[0].edge, expected);
let mut moved = panels[0].clone();
moved.edge = if expected == Edge::Top { Edge::Bottom } else { Edge::Top };
moved.edge = if expected == Edge::Top {
Edge::Bottom
} else {
Edge::Top
};
assert!(!moved.same_composition(&panels[0]));
}
}
@@ -177,7 +198,9 @@ fn validation_rejects_duplicate_ids_and_unsupported_surface_configuration() {
assert!(validate(std::slice::from_ref(&panel)).is_err());
assert!(validate(&[]).is_err());
assert!(validate(&[panel.clone(), panel]).is_err());
assert!(ferese_config::from_str::<serde_json::Value>("panel \"main\" { edge \"bottom\"; }").is_ok());
assert!(
ferese_config::from_str::<serde_json::Value>("panel \"main\" { edge \"bottom\"; }").is_ok()
);
let doc = ferese_config::Document::parse("panel \"main\" { edge \"left\"; }").unwrap();
assert!(serde_json::from_value::<Vec<Panel>>(doc.get("panels").unwrap().clone()).is_err());
}
@@ -189,13 +212,18 @@ fn workspace_styles_round_trip_and_only_apply_to_workspaces() {
style.key()
);
let document = ferese_config::Document::parse(&source).unwrap();
let panels: Vec<Panel> = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
let panels: Vec<Panel> =
serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
validate(&panels).unwrap();
assert_eq!(panels[0].start.groups[0].items[0].kind, ItemKind::Workspaces { style });
assert_eq!(
panels[0].start.groups[0].items[0].kind,
ItemKind::Workspaces { style }
);
let mut wrong_kind = serde_json::to_value(&panels[0].start.groups[0].items[0]).unwrap();
wrong_kind["kind"] = "clock".into();
assert!(serde_json::from_value::<Item>(wrong_kind).is_err());
let restored: Vec<Panel> = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
let restored: Vec<Panel> =
serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
assert_eq!(restored, panels);
}
let mut item = serde_json::json!({"id": "spaces", "kind": "workspaces"});
@@ -214,8 +242,13 @@ fn item_options_are_strict_and_metadata_is_explicit() {
for key in ["ovreflow", "visibile", "custom_note"] {
let mut value = serde_json::json!({"id":"network", "kind":"network"});
value[key] = "always".into();
let error = serde_json::from_value::<Item>(value).unwrap_err().to_string();
assert!(error.contains(key) && error.contains("unknown field"), "{error}");
let error = serde_json::from_value::<Item>(value)
.unwrap_err()
.to_string();
assert!(
error.contains(key) && error.contains("unknown field"),
"{error}"
);
}
let source = "panel main { start { group nav { item network kind=\"network\" { metadata { custom-note \"keep\"; overflow \"authored note\"; }; }; }; }; }";
let panel: Vec<Panel> = serde_json::from_value(
@@ -234,8 +267,10 @@ fn item_options_are_strict_and_metadata_is_explicit() {
*item
);
assert!(
serde_json::from_value::<Item>(serde_json::json!({"id":"network", "kind":"network", "metadata":"wrong"}))
.is_err()
serde_json::from_value::<Item>(
serde_json::json!({"id":"network", "kind":"network", "metadata":"wrong"})
)
.is_err()
);
}
@@ -247,16 +282,24 @@ fn activities_are_first_class_items_in_every_zone_and_keep_overflow_settings() {
"panel main {{ style {style}; {zone} {{ group tasks {{ item builds kind=activities priority=20 overflow=always; }}; }}; }}"
);
let mut doc = ferese_config::Document::parse(&source).unwrap();
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
let panels: Vec<Panel> =
serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
ferese_panel::validate(&panels).unwrap();
let parsed = match zone {
"start" => &panels[0].start,
"center" => &panels[0].center,
_ => &panels[0].end,
};
assert_eq!(parsed.groups[0].items[0].kind, ferese_panel::ItemKind::Activities);
assert_eq!(parsed.groups[0].items[0].overflow, ferese_panel::OverflowPolicy::Always);
doc.set("panels", serde_json::to_value(&panels).unwrap()).unwrap();
assert_eq!(
parsed.groups[0].items[0].kind,
ferese_panel::ItemKind::Activities
);
assert_eq!(
parsed.groups[0].items[0].overflow,
ferese_panel::OverflowPolicy::Always
);
doc.set("panels", serde_json::to_value(&panels).unwrap())
.unwrap();
let restored = ferese_config::Document::parse(&doc.to_string()).unwrap();
assert_eq!(restored.get("panels"), doc.get("panels"));
}
+27 -6
View File
@@ -21,7 +21,11 @@ impl FocusHistory {
self.windows.retain(|id| *id != window);
}
pub fn candidates(&self, current: Option<WindowId>, available: impl AsRef<[WindowId]>) -> Vec<WindowId> {
pub fn candidates(
&self,
current: Option<WindowId>,
available: impl AsRef<[WindowId]>,
) -> Vec<WindowId> {
let mut remaining = available.as_ref().iter().copied().collect::<HashSet<_>>();
let mut ordered = Vec::with_capacity(remaining.len());
@@ -71,7 +75,12 @@ impl FocusCycle {
let old = self
.selected
.and_then(|id| self.windows.iter().position(|candidate| *candidate == id));
let before = old.map(|index| self.windows[..index].iter().filter(|id| available.contains(id)).count());
let before = old.map(|index| {
self.windows[..index]
.iter()
.filter(|id| available.contains(id))
.count()
});
let survives = self.selected.is_some_and(|id| available.contains(&id));
self.windows.retain(|id| available.contains(id));
if self.windows.is_empty() {
@@ -142,7 +151,10 @@ mod tests {
[3, 2, 1, 4].map(WindowId)
);
history.record(WindowId(2));
assert_eq!(history.candidates(Some(WindowId(2)), available)[1], WindowId(3));
assert_eq!(
history.candidates(Some(WindowId(2)), available)[1],
WindowId(3)
);
history.remove(WindowId(3));
assert_eq!(
history.candidates(None, vec![WindowId(1), WindowId(4)]),
@@ -158,7 +170,10 @@ mod tests {
assert_eq!(cycle.advance(false, &available), Some(WindowId(1)));
assert_eq!(cycle.advance(false, &available), Some(WindowId(3)));
assert_eq!(cycle.advance(true, &available), Some(WindowId(1)));
assert_eq!(cycle.advance(false, &[WindowId(3), WindowId(2)]), Some(WindowId(3)));
assert_eq!(
cycle.advance(false, &[WindowId(3), WindowId(2)]),
Some(WindowId(3))
);
assert_eq!(cycle.advance(false, &[]), None);
}
@@ -177,7 +192,13 @@ mod tests {
let available = [3, 2, 1].map(WindowId);
let mut cycle = FocusCycle::new(available.to_vec(), Some(WindowId(3)));
assert_eq!(cycle.advance(false, &available), Some(WindowId(2)));
assert_eq!(cycle.reconcile(&[WindowId(3), WindowId(1)]), Some(WindowId(1)));
assert_eq!(cycle.reconcile(&[WindowId(3), WindowId(1)]), Some(WindowId(1)));
assert_eq!(
cycle.reconcile(&[WindowId(3), WindowId(1)]),
Some(WindowId(1))
);
assert_eq!(
cycle.reconcile(&[WindowId(3), WindowId(1)]),
Some(WindowId(1))
);
}
}
+5 -2
View File
@@ -4,8 +4,11 @@ mod workspace;
mod workspace_policy;
pub use focus::{FocusCycle, FocusHistory};
pub use output::{DesktopPlan, OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use output::{
DesktopPlan, OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch,
};
pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout,
WorkspaceSet,
};
pub use workspace_policy::WorkspaceView;
+345 -98
View File
@@ -17,7 +17,12 @@ pub struct OutputGeometry {
impl OutputGeometry {
pub const fn new(x: i32, y: i32, width: i32, height: i32) -> Self {
Self { x, y, width, height }
Self {
x,
y,
width,
height,
}
}
fn center(self) -> (i64, i64) {
@@ -33,7 +38,10 @@ pub enum OutputError {
AlreadyConnected(OutputId),
UnknownOutput(OutputId),
InvalidGeometry(OutputId),
WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId },
WorkspaceAlreadyAssigned {
workspace: WorkspaceId,
output: OutputId,
},
}
impl fmt::Display for OutputError {
@@ -41,9 +49,14 @@ impl fmt::Display for OutputError {
match self {
Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"),
Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"),
Self::InvalidGeometry(output) => write!(formatter, "invalid geometry for output {output:?}"),
Self::InvalidGeometry(output) => {
write!(formatter, "invalid geometry for output {output:?}")
}
Self::WorkspaceAlreadyAssigned { workspace, output } => {
write!(formatter, "workspace {workspace:?} is assigned to {output:?}")
write!(
formatter,
"workspace {workspace:?} is assigned to {output:?}"
)
}
}
}
@@ -68,7 +81,10 @@ struct OutputState {
impl OutputState {
fn activate(&mut self, workspace: WorkspaceId) {
if self.active != workspace {
self.previous = self.workspaces.contains(&self.active).then_some(self.active);
self.previous = self
.workspaces
.contains(&self.active)
.then_some(self.active);
self.active = workspace;
}
}
@@ -148,12 +164,17 @@ impl OutputWorkspaceMap {
.copied()
.filter(|workspace| {
outputs.output_for_workspace(*workspace).is_none()
&& outputs.home_output(*workspace).is_none_or(|home| home == id)
&& outputs
.home_output(*workspace)
.is_none_or(|home| home == id)
})
.min()
.unwrap_or_else(|| {
let workspace = next_workspace;
next_workspace.0 = next_workspace.0.checked_add(1).expect("workspace IDs exhausted");
next_workspace.0 = next_workspace
.0
.checked_add(1)
.expect("workspace IDs exhausted");
created_workspaces.push(workspace);
workspace
});
@@ -202,7 +223,10 @@ impl OutputWorkspaceMap {
})
}
pub fn assigned_workspaces(&self, output: OutputId) -> impl DoubleEndedIterator<Item = WorkspaceId> + '_ {
pub fn assigned_workspaces(
&self,
output: OutputId,
) -> impl DoubleEndedIterator<Item = WorkspaceId> + '_ {
self.outputs
.get(&output)
.into_iter()
@@ -230,7 +254,11 @@ impl OutputWorkspaceMap {
}
pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool {
if self.outputs.values().any(|output| output.active == workspace) {
if self
.outputs
.values()
.any(|output| output.active == workspace)
{
return false;
}
if let Some(owner) = self.assignments.remove(&workspace)
@@ -247,7 +275,9 @@ impl OutputWorkspaceMap {
self.orphaned_focus = None;
}
self.evacuations.retain(|_, record| {
record.workspaces.retain(|entry| entry.workspace != workspace);
record
.workspaces
.retain(|entry| entry.workspace != workspace);
if record.active == workspace {
if let Some(entry) = record.workspaces.first() {
record.active = entry.workspace;
@@ -282,7 +312,11 @@ impl OutputWorkspaceMap {
.assignments
.get(&evacuated.workspace)
.copied()
.or_else(|| evacuated.target.filter(|target| self.outputs.contains_key(target)));
.or_else(|| {
evacuated
.target
.filter(|target| self.outputs.contains_key(target))
});
let assignment_matches = self.homes.get(&evacuated.workspace) == Some(&output);
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
@@ -296,7 +330,10 @@ impl OutputWorkspaceMap {
// the reconnecting monitor can use its fresh fallback.
if target
.and_then(|target| self.outputs.get(&target))
.is_some_and(|state| state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace))
.is_some_and(|state| {
state.workspaces.len() == 1
&& state.workspaces.contains(&evacuated.workspace)
})
{
continue;
}
@@ -344,7 +381,12 @@ impl OutputWorkspaceMap {
let active = record
.as_ref()
.and_then(|record| reclaimed.contains(&record.active).then_some(record.active))
.or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0));
.or_else(|| {
reclaimed
.iter()
.copied()
.min_by_key(|workspace| workspace.0)
});
let mut active = match active {
Some(active) => active,
None => {
@@ -398,7 +440,10 @@ impl OutputWorkspaceMap {
}
pub fn disconnect(&mut self, output: OutputId) -> Result<Option<OutputId>, OutputError> {
let removed = self.outputs.remove(&output).ok_or(OutputError::UnknownOutput(output))?;
let removed = self
.outputs
.remove(&output)
.ok_or(OutputError::UnknownOutput(output))?;
let target = self.evacuate(output, removed);
debug_assert!(self.validate());
Ok(target)
@@ -483,7 +528,10 @@ impl OutputWorkspaceMap {
}
let reassigned = self.assignments.insert(workspace, output) != Some(output);
let state = self.outputs.get_mut(&output).expect("output was checked above");
let state = self
.outputs
.get_mut(&output)
.expect("output was checked above");
state.workspaces.insert(workspace);
state.activate(workspace);
self.focused = Some(output);
@@ -510,7 +558,11 @@ impl OutputWorkspaceMap {
self.switch_workspace(output, workspace)
}
pub fn assign_workspace(&mut self, output: OutputId, workspace: WorkspaceId) -> Result<OutputId, OutputError> {
pub fn assign_workspace(
&mut self,
output: OutputId,
workspace: WorkspaceId,
) -> Result<OutputId, OutputError> {
if !self.outputs.contains_key(&output) {
return Err(OutputError::UnknownOutput(output));
}
@@ -545,7 +597,10 @@ impl OutputWorkspaceMap {
if let Some(owner) = self.assignments.get(&workspace).copied()
&& owner != output
{
let donor = self.outputs.get_mut(&owner).expect("assigned owner is connected");
let donor = self
.outputs
.get_mut(&owner)
.expect("assigned owner is connected");
if donor.workspaces.len() == 1 {
if let Some(assigned) = self.assignments.get(&donor_fallback) {
return Err(OutputError::WorkspaceAlreadyAssigned {
@@ -570,13 +625,21 @@ impl OutputWorkspaceMap {
}
self.assignments.insert(workspace, output);
self.homes.insert(workspace, output);
self.outputs.get_mut(&output).unwrap().workspaces.insert(workspace);
self.outputs
.get_mut(&output)
.unwrap()
.workspaces
.insert(workspace);
self.bump_revision(workspace);
debug_assert!(self.validate());
Ok(())
}
fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> {
fn migration_target(
&self,
removed: OutputId,
removed_geometry: OutputGeometry,
) -> Option<OutputId> {
if let Some(focused) = self.focused
&& focused != removed
&& self.outputs.contains_key(&focused)
@@ -606,7 +669,10 @@ impl OutputWorkspaceMap {
}
fn validate(&self) -> bool {
if self.focused.is_some_and(|focused| !self.outputs.contains_key(&focused)) {
if self
.focused
.is_some_and(|focused| !self.outputs.contains_key(&focused))
{
return false;
}
@@ -614,10 +680,9 @@ impl OutputWorkspaceMap {
if !state.workspaces.contains(&state.active) {
return false;
}
if state
.previous
.is_some_and(|previous| previous == state.active || !state.workspaces.contains(&previous))
{
if state.previous.is_some_and(|previous| {
previous == state.active || !state.workspaces.contains(&previous)
}) {
return false;
}
for workspace in &state.workspaces {
@@ -662,8 +727,14 @@ mod tests {
assert!(!unchanged.focus_changed);
let removed = plan(&second.outputs, &[1]);
assert_eq!(removed.outputs.focused_output(), Some(OutputId(1)));
assert_eq!(removed.outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(removed.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
assert_eq!(
removed.outputs.active_workspace(OutputId(1)),
Some(WorkspaceId(1))
);
assert_eq!(
removed.outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(1))
);
}
#[test]
@@ -672,7 +743,10 @@ mod tests {
before.focus_output(OutputId(2)).unwrap();
let after = plan(&before, &[4]);
assert_eq!(after.outputs.focused_output(), Some(OutputId(4)));
assert_eq!(after.outputs.active_workspace(OutputId(4)), Some(WorkspaceId(2)));
assert_eq!(
after.outputs.active_workspace(OutputId(4)),
Some(WorkspaceId(2))
);
for workspace in 1..=3 {
assert_eq!(
after.outputs.output_for_workspace(WorkspaceId(workspace)),
@@ -695,7 +769,10 @@ mod tests {
let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
let mut evacuated = plan(&before, &[1]).outputs;
let restored = plan(&evacuated, &[1, 2]);
assert_eq!(restored.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(
restored.outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
evacuated
.reassign_workspace(OutputId(1), WorkspaceId(2), WorkspaceId(9))
.unwrap();
@@ -711,11 +788,16 @@ mod tests {
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs;
before.focus_output(OutputId(2)).unwrap();
before.disconnect(OutputId(1)).unwrap();
before.switch_workspace(OutputId(2), WorkspaceId(1)).unwrap();
before
.switch_workspace(OutputId(2), WorkspaceId(1))
.unwrap();
assert!(before.forget_workspace(WorkspaceId(2)));
before.focus_output(OutputId(3)).unwrap();
let after = plan(&before, &[1, 3]).outputs;
assert_eq!(after.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(
after.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert_eq!(after.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(after.focused_output(), Some(OutputId(3)));
}
@@ -735,7 +817,11 @@ mod tests {
);
assert!(
before
.plan_desktop(&[(OutputId(3), OutputGeometry::new(0, 0, 0, 800))], &[], WorkspaceId(3))
.plan_desktop(
&[(OutputId(3), OutputGeometry::new(0, 0, 0, 800))],
&[],
WorkspaceId(3)
)
.is_err()
);
assert_eq!(before, saved);
@@ -749,9 +835,15 @@ mod tests {
assert_eq!(headless.focused_output(), None);
assert!(headless.assignments.is_empty());
let recovered = plan(&headless, &[3]).outputs;
assert_eq!(recovered.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
assert_eq!(
recovered.active_workspace(OutputId(3)),
Some(WorkspaceId(2))
);
for id in [1, 2] {
assert_eq!(recovered.output_for_workspace(WorkspaceId(id)), Some(OutputId(3)));
assert_eq!(
recovered.output_for_workspace(WorkspaceId(id)),
Some(OutputId(3))
);
}
}
@@ -761,7 +853,9 @@ mod tests {
let mut seed = 0x5eed_u64;
for step in 0..512 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let ids = (1..=4).filter(|id| seed & (1 << id) != 0).collect::<Vec<_>>();
let ids = (1..=4)
.filter(|id| seed & (1 << id) != 0)
.collect::<Vec<_>>();
let next = plan(&map, &ids);
assert!(next.outputs.validate(), "step {step}");
assert_eq!(plan(&next.outputs, &ids).outputs, next.outputs);
@@ -793,7 +887,10 @@ mod tests {
.filter(|id| seed & (1 << id) != 0)
.map(|id| (OutputId(id), geometry(id as i32 * 800)))
.collect::<Vec<_>>();
let ids = workspaces.iter().map(|workspace| workspace.id).collect::<Vec<_>>();
let ids = workspaces
.iter()
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
let plan = outputs
.plan_desktop(&inventory, &ids, workspaces.next_workspace_id())
.unwrap();
@@ -803,7 +900,9 @@ mod tests {
outputs = plan.outputs;
outputs.reconcile_workspaces(&mut workspaces, &Default::default());
let window = WindowId(step);
workspaces.insert_window(window, Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(window, Axis::Horizontal, 0.5)
.unwrap();
windows.insert(window);
if let Some((output, _)) = inventory.last() {
outputs.focus_output(*output).unwrap();
@@ -817,10 +916,22 @@ mod tests {
workspaces.validate().unwrap();
let actual = workspaces
.iter()
.flat_map(|workspace| workspace.layout.window_ids().chain(workspace.floating.iter().copied()))
.flat_map(|workspace| {
workspace
.layout
.window_ids()
.chain(workspace.floating.iter().copied())
})
.collect::<Vec<_>>();
assert_eq!(actual.len(), windows.len(), "duplicate or lost window at {step}");
assert_eq!(actual.into_iter().collect::<std::collections::HashSet<_>>(), windows);
assert_eq!(
actual.len(),
windows.len(),
"duplicate or lost window at {step}"
);
assert_eq!(
actual.into_iter().collect::<std::collections::HashSet<_>>(),
windows
);
for workspace in workspaces.iter().filter(|workspace| !workspace.is_empty()) {
assert_eq!(
outputs.output_for_workspace(workspace.id).is_some(),
@@ -833,13 +944,19 @@ mod tests {
#[test]
fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1920), WorkspaceId(2))
.unwrap();
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert!(outputs.forget_workspace(WorkspaceId(1)));
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(3)).unwrap();
outputs
.connect(OutputId(2), geometry(1920), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(3)));
@@ -859,7 +976,9 @@ mod tests {
assert!(outputs.forget_workspace(first));
assert_eq!(outputs.output_for_workspace(first), None);
outputs.disconnect(display).unwrap();
outputs.connect(display, geometry, super::WorkspaceId(3)).unwrap();
outputs
.connect(display, geometry, super::WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(display), Some(second));
assert_eq!(outputs.output_for_workspace(first), None);
assert!(outputs.validate());
@@ -888,14 +1007,20 @@ mod tests {
fn history_tracks_actual_switches_and_toggles_without_replacing_it_on_a_noop() {
let mut outputs = OutputWorkspaceMap::default();
let display = OutputId(1);
outputs.connect(display, geometry(0), WorkspaceId(1)).unwrap();
outputs
.connect(display, geometry(0), WorkspaceId(1))
.unwrap();
assert_eq!(outputs.previous_workspace(display), None);
outputs.select_workspace(display, WorkspaceId(1), true).unwrap();
outputs
.select_workspace(display, WorkspaceId(1), true)
.unwrap();
assert_eq!(outputs.previous_workspace(display), None);
outputs.switch_workspace(display, WorkspaceId(2)).unwrap();
outputs.switch_workspace(display, WorkspaceId(2)).unwrap();
assert_eq!(outputs.previous_workspace(display), Some(WorkspaceId(1)));
outputs.select_workspace(display, WorkspaceId(2), false).unwrap();
outputs
.select_workspace(display, WorkspaceId(2), false)
.unwrap();
assert_eq!(outputs.active_workspace(display), Some(WorkspaceId(2)));
for expected in [WorkspaceId(1), WorkspaceId(2), WorkspaceId(1)] {
let current = outputs.active_workspace(display).unwrap();
@@ -912,7 +1037,9 @@ mod tests {
let left = OutputId(1);
let right = OutputId(2);
outputs.connect(left, geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(right, geometry(1920), WorkspaceId(2)).unwrap();
outputs
.connect(right, geometry(1920), WorkspaceId(2))
.unwrap();
outputs.switch_workspace(left, WorkspaceId(3)).unwrap();
outputs.switch_workspace(right, WorkspaceId(4)).unwrap();
assert_eq!(
@@ -935,13 +1062,17 @@ mod tests {
let left = OutputId(1);
let right = OutputId(2);
outputs.connect(left, geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(right, geometry(1920), WorkspaceId(2)).unwrap();
outputs
.connect(right, geometry(1920), WorkspaceId(2))
.unwrap();
outputs.focus_output(right).unwrap();
outputs.disconnect(right).unwrap();
assert_eq!(outputs.previous_workspace(left), Some(WorkspaceId(1)));
outputs.switch_workspace(left, WorkspaceId(1)).unwrap();
assert_eq!(outputs.previous_workspace(left), Some(WorkspaceId(2)));
outputs.connect(right, geometry(1920), WorkspaceId(3)).unwrap();
outputs
.connect(right, geometry(1920), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.previous_workspace(left), None);
assert_eq!(outputs.previous_workspace(right), None);
assert!(outputs.validate());
@@ -951,11 +1082,15 @@ mod tests {
fn forgetting_a_previous_workspace_clears_its_history() {
let mut outputs = OutputWorkspaceMap::default();
let display = OutputId(1);
outputs.connect(display, geometry(0), WorkspaceId(1)).unwrap();
outputs
.connect(display, geometry(0), WorkspaceId(1))
.unwrap();
outputs.switch_workspace(display, WorkspaceId(2)).unwrap();
assert!(outputs.forget_workspace(WorkspaceId(1)));
assert_eq!(outputs.previous_workspace(display), None);
outputs.select_workspace(display, WorkspaceId(2), true).unwrap();
outputs
.select_workspace(display, WorkspaceId(2), true)
.unwrap();
assert_eq!(outputs.active_workspace(display), Some(WorkspaceId(2)));
assert_eq!(
outputs.select_workspace(OutputId(99), WorkspaceId(2), true),
@@ -975,28 +1110,47 @@ mod tests {
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)),
Ok(WorkspaceId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
}
#[test]
fn removal_prefers_a_remaining_focused_output_without_replacing_its_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.focus_output(OutputId(1)).unwrap();
assert_eq!(outputs.disconnect(OutputId(2)), Ok(Some(OutputId(1))));
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(1))
);
}
#[test]
fn removing_the_focused_output_activates_its_workspace_on_the_nearest_output() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs.connect(OutputId(3), geometry(3_840), WorkspaceId(3)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs
.connect(OutputId(3), geometry(3_840), WorkspaceId(3))
.unwrap();
outputs.focus_output(OutputId(2)).unwrap();
assert_eq!(outputs.disconnect(OutputId(2)), Ok(Some(OutputId(1))));
@@ -1007,8 +1161,12 @@ mod tests {
#[test]
fn reconnect_reclaims_automatically_evacuated_workspaces() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.focus_output(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
@@ -1016,20 +1174,29 @@ mod tests {
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)),
Ok(WorkspaceId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2)));
}
#[test]
fn reconnect_restores_the_migration_targets_remaining_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2)));
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)).unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2)));
@@ -1038,24 +1205,37 @@ mod tests {
#[test]
fn using_an_evacuated_workspace_does_not_prevent_automatic_return() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.focus_output(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
outputs.switch_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs
.switch_workspace(OutputId(1), WorkspaceId(2))
.unwrap();
assert_eq!(
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)),
Ok(WorkspaceId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
}
#[test]
fn repeated_lid_and_monitor_handoffs_restore_both_workspace_owners() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1920), WorkspaceId(2))
.unwrap();
for removed in [OutputId(1), OutputId(2), OutputId(1), OutputId(2)] {
let remaining = if removed == OutputId(1) {
OutputId(2)
@@ -1064,8 +1244,14 @@ mod tests {
};
outputs.focus_output(removed).unwrap();
outputs.disconnect(removed).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(remaining));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(remaining));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(remaining)
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(remaining)
);
// Switching between handed-off workspaces is normal use, not an
// explicit change of their home output.
outputs.switch_workspace(remaining, WorkspaceId(1)).unwrap();
@@ -1077,8 +1263,14 @@ mod tests {
WorkspaceId(3),
)
.unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2)));
}
@@ -1087,8 +1279,12 @@ mod tests {
#[test]
fn selecting_a_workspace_owned_by_another_output_focuses_that_output() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
assert_eq!(
outputs.switch_workspace(OutputId(1), WorkspaceId(2)),
@@ -1100,11 +1296,19 @@ mod tests {
#[test]
fn assigning_an_unused_workspace_does_not_change_the_active_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
assert_eq!(outputs.assign_workspace(OutputId(1), WorkspaceId(2)), Ok(OutputId(1)));
assert_eq!(
outputs.assign_workspace(OutputId(1), WorkspaceId(2)),
Ok(OutputId(1))
);
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(1))
);
}
#[test]
fn two_hop_evacuation_restores_original_home() {
@@ -1117,51 +1321,94 @@ mod tests {
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(3))
);
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
outputs.connect(OutputId(1), geometry(0), WorkspaceId(4)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(4))
.unwrap();
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert!(outputs.active_workspace(OutputId(3)).is_some());
}
#[test]
fn explicit_reassignment_after_evacuation_prevents_home_steal_back() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1920), WorkspaceId(2))
.unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(9))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(3)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(3))
.unwrap();
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(2))
);
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(2)));
}
#[test]
fn reassigning_last_workspace_supplies_donor_fallback() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1920), WorkspaceId(2))
.unwrap();
outputs
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(3)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(2))
);
}
#[test]
fn final_output_replacement_keeps_all_workspaces_reachable_without_changing_homes() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.assign_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs.switch_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.assign_workspace(OutputId(1), WorkspaceId(2))
.unwrap();
outputs
.switch_workspace(OutputId(1), WorkspaceId(2))
.unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs.connect(OutputId(3), geometry(0), WorkspaceId(3)).unwrap();
outputs
.connect(OutputId(3), geometry(0), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(3))
);
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
outputs.connect(OutputId(1), geometry(1920), WorkspaceId(4)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
outputs
.connect(OutputId(1), geometry(1920), WorkspaceId(4))
.unwrap();
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(1))
);
}
}
+392 -106
View File
@@ -3,8 +3,8 @@ use std::error::Error;
use std::fmt;
use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect, ScrollingLayout,
SizeConstraints, ViewportFocusStrategy, WindowId,
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect,
ScrollingLayout, SizeConstraints, ViewportFocusStrategy, WindowId,
};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
@@ -39,7 +39,11 @@ impl Default for WorkspaceLayout {
}
impl WorkspaceLayout {
pub fn new(mode: LayoutMode, default_column_width: ColumnWidth, focus_strategy: ViewportFocusStrategy) -> Self {
pub fn new(
mode: LayoutMode,
default_column_width: ColumnWidth,
focus_strategy: ViewportFocusStrategy,
) -> Self {
match mode {
LayoutMode::Scrolling => {
let mut layout = ScrollingLayout::with_default_width(default_column_width);
@@ -95,7 +99,10 @@ impl WorkspaceLayout {
Self::Scrolling(_) => None,
};
scrolling.into_iter().flatten().chain(tree.into_iter().flatten())
scrolling
.into_iter()
.flatten()
.chain(tree.into_iter().flatten())
}
pub fn insert(
@@ -158,7 +165,12 @@ impl WorkspaceLayout {
Self::Scrolling(layout) => {
let mut layout = layout.clone();
layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), None)
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
None,
)
.map(|result| result.geometry)
}
Self::Tree(layout) => layout.geometry(bounds),
@@ -190,17 +202,22 @@ impl WorkspaceLayout {
for id in &group.windows {
let constraint = constraints.entry(*id).or_default();
constraint.min_height += TAB_HEIGHT;
constraint.max_height = constraint.max_height.map(|height| height + TAB_HEIGHT);
constraint.max_height =
constraint.max_height.map(|height| height + TAB_HEIGHT);
}
}
let mut result = layout.geometry_with_constraints(bounds, gaps, &constraints, focused)?;
let mut result =
layout.geometry_with_constraints(bounds, gaps, &constraints, focused)?;
for group in groups {
if let Some(rect) = result.geometry.get_mut(&group.active) {
let height = TAB_HEIGHT.min((rect.height - 1.0).max(0.0));
let header = Rect::new(rect.x, rect.y, rect.width, height);
rect.y += height;
rect.height -= height;
result.tab_bars.push(ferese_layout::TabBar { group, rect: header });
result.tab_bars.push(ferese_layout::TabBar {
group,
rect: header,
});
}
}
Ok(result)
@@ -208,7 +225,11 @@ impl WorkspaceLayout {
}
}
pub fn automatic_axis(&self, focused: Option<WindowId>, bounds: Rect) -> Result<Axis, LayoutError> {
pub fn automatic_axis(
&self,
focused: Option<WindowId>,
bounds: Rect,
) -> Result<Axis, LayoutError> {
match self {
Self::Scrolling(_) => Ok(Axis::Horizontal),
Self::Tree(layout) => layout.automatic_axis(focused, bounds),
@@ -227,14 +248,24 @@ impl WorkspaceLayout {
}
}
pub fn move_window(&mut self, window: WindowId, direction: Direction, bounds: Rect) -> Result<bool, LayoutError> {
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
bounds: Rect,
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.move_window(window, direction),
Self::Tree(layout) => layout.move_window(window, direction, bounds),
}
}
pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result<bool, LayoutError> {
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.resize_window(window, direction, amount),
Self::Tree(layout) => layout.resize_window(window, direction, amount),
@@ -256,7 +287,11 @@ impl WorkspaceLayout {
}
}
pub fn cycle_column_width(&mut self, window: WindowId, presets: &[ColumnWidth]) -> Result<bool, LayoutError> {
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.cycle_column_width(window, presets),
Self::Tree(_) => Ok(false),
@@ -303,7 +338,11 @@ impl WorkspaceLayout {
for column in columns {
for (row, window) in column.windows.into_iter().enumerate() {
let axis = if row == 0 { Axis::Horizontal } else { Axis::Vertical };
let axis = if row == 0 {
Axis::Horizontal
} else {
Axis::Vertical
};
tree.insert(window, previous, axis, 0.5)?;
previous = Some(window);
}
@@ -414,7 +453,11 @@ impl WorkspaceSet {
let active = WorkspaceId(1);
let workspace = Workspace {
id: active,
layout: WorkspaceLayout::new(default_layout_mode, default_column_width, scrolling_focus_strategy),
layout: WorkspaceLayout::new(
default_layout_mode,
default_column_width,
scrolling_focus_strategy,
),
floating: Vec::new(),
last_focused: None,
fullscreen: None,
@@ -502,7 +545,12 @@ impl WorkspaceSet {
}
/// Update policy without moving windows or replacing existing column widths.
pub fn reconfigure_defaults(&mut self, mode: LayoutMode, width: ColumnWidth, strategy: ViewportFocusStrategy) {
pub fn reconfigure_defaults(
&mut self,
mode: LayoutMode,
width: ColumnWidth,
strategy: ViewportFocusStrategy,
) {
self.default_layout_mode = mode;
self.default_column_width = width;
self.scrolling_focus_strategy = strategy;
@@ -539,8 +587,12 @@ impl WorkspaceSet {
debug_assert!(self.validate().is_ok());
let focused = self.active().last_focused;
let visible_floating =
focused.filter(|window| matches!(self.placement(*window), Some(WindowPlacement::Floating { .. })));
let visible_floating = focused.filter(|window| {
matches!(
self.placement(*window),
Some(WindowPlacement::Floating { .. })
)
});
Ok(visible_floating.or(self.active().fullscreen).or(focused))
}
@@ -564,15 +616,23 @@ impl WorkspaceSet {
self.placements.get(&window).copied()
}
pub fn set_active_layout_mode(&mut self, mode: LayoutMode, bounds: Rect) -> Result<bool, WorkspaceError> {
pub fn set_active_layout_mode(
&mut self,
mode: LayoutMode,
bounds: Rect,
) -> Result<bool, WorkspaceError> {
let default_column_width = self.default_column_width;
let focus_strategy = self.scrolling_focus_strategy;
let display = self.default_column_display;
let workspace = self.active_mut();
let focused = tiled_focus(workspace);
let changed = workspace
.layout
.set_mode(mode, bounds, focused, default_column_width, focus_strategy)?;
let changed = workspace.layout.set_mode(
mode,
bounds,
focused,
default_column_width,
focus_strategy,
)?;
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
layout.set_default_display(display);
}
@@ -581,7 +641,11 @@ impl WorkspaceSet {
Ok(changed)
}
pub fn set_floating_rect(&mut self, window: WindowId, rect: Rect) -> Result<(), WorkspaceError> {
pub fn set_floating_rect(
&mut self,
window: WindowId,
rect: Rect,
) -> Result<(), WorkspaceError> {
let placement = self
.placements
.get_mut(&window)
@@ -607,23 +671,35 @@ impl WorkspaceSet {
self.focus_window_with_reveal(window, false)
}
fn focus_window_with_reveal(&mut self, window: WindowId, reveal: bool) -> Result<(), WorkspaceError> {
fn focus_window_with_reveal(
&mut self,
window: WindowId,
reveal: bool,
) -> Result<(), WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active) {
return Err(WorkspaceError::InvalidState(
"focused window is not on the active workspace",
));
}
if self.active().fullscreen.is_some_and(|fullscreen| {
fullscreen != window && !matches!(self.placement(window), Some(WindowPlacement::Floating { .. }))
fullscreen != window
&& !matches!(
self.placement(window),
Some(WindowPlacement::Floating { .. })
)
}) {
return Err(WorkspaceError::InvalidState("focused window is hidden by fullscreen"));
return Err(WorkspaceError::InvalidState(
"focused window is hidden by fullscreen",
));
}
if self.placement(window) == Some(WindowPlacement::Tiled) {
if reveal {
self.active_mut().layout.activate_window(window)?;
} else {
self.active_mut().layout.activate_window_without_reveal(window)?;
self.active_mut()
.layout
.activate_window_without_reveal(window)?;
}
}
self.active_mut().last_focused = Some(window);
@@ -632,7 +708,11 @@ impl WorkspaceSet {
Ok(())
}
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), WorkspaceError> {
pub fn stack_window(
&mut self,
window: WindowId,
target: WindowId,
) -> Result<(), WorkspaceError> {
let workspace = self
.workspace_for_window(window)
.ok_or(LayoutError::UnknownWindow(window))?;
@@ -673,7 +753,11 @@ impl WorkspaceSet {
Ok(changed)
}
pub fn cycle_column_width(&mut self, window: WindowId, presets: &[ColumnWidth]) -> Result<bool, WorkspaceError> {
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active)
|| self.placement(window) != Some(WindowPlacement::Tiled)
{
@@ -682,7 +766,10 @@ impl WorkspaceSet {
));
}
let changed = self.active_mut().layout.cycle_column_width(window, presets)?;
let changed = self
.active_mut()
.layout
.cycle_column_width(window, presets)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
@@ -743,7 +830,10 @@ impl WorkspaceSet {
/// Creates an identity; display positions are assigned by the output owner.
pub fn create_workspace(&mut self) -> WorkspaceId {
let id = WorkspaceId(self.next_id);
self.next_id = self.next_id.checked_add(1).expect("workspace IDs exhausted");
self.next_id = self
.next_id
.checked_add(1)
.expect("workspace IDs exhausted");
self.workspaces.insert(
id,
Workspace {
@@ -770,7 +860,12 @@ impl WorkspaceSet {
self.activate(workspace)
}
pub fn insert_window(&mut self, window: WindowId, axis: Axis, ratio: f64) -> Result<(), WorkspaceError> {
pub fn insert_window(
&mut self,
window: WindowId,
axis: Axis,
ratio: f64,
) -> Result<(), WorkspaceError> {
if self.window_workspaces.contains_key(&window) {
return Err(WorkspaceError::DuplicateWindow(window));
}
@@ -980,7 +1075,11 @@ impl WorkspaceSet {
Ok(enabled)
}
pub fn set_fullscreen(&mut self, window: WindowId, enabled: bool) -> Result<bool, WorkspaceError> {
pub fn set_fullscreen(
&mut self,
window: WindowId,
enabled: bool,
) -> Result<bool, WorkspaceError> {
let workspace_id = self
.workspace_for_window(window)
.ok_or(LayoutError::UnknownWindow(window))?;
@@ -1014,7 +1113,9 @@ impl WorkspaceSet {
for (id, workspace) in &self.workspaces {
if workspace.id != *id {
return Err(WorkspaceError::InvalidState("workspace index is inconsistent"));
return Err(WorkspaceError::InvalidState(
"workspace index is inconsistent",
));
}
workspace.layout.validate()?;
@@ -1024,7 +1125,9 @@ impl WorkspaceSet {
|| self.window_workspaces.get(&window) != Some(id)
|| self.placements.get(&window) != Some(&WindowPlacement::Tiled)
{
return Err(WorkspaceError::InvalidState("window ownership is inconsistent"));
return Err(WorkspaceError::InvalidState(
"window ownership is inconsistent",
));
}
}
@@ -1034,7 +1137,10 @@ impl WorkspaceSet {
if !seen_floating.insert(*window)
|| !seen_windows.insert(*window)
|| self.window_workspaces.get(window) != Some(id)
|| !matches!(self.placements.get(window), Some(WindowPlacement::Floating { .. }))
|| !matches!(
self.placements.get(window),
Some(WindowPlacement::Floating { .. })
)
{
return Err(WorkspaceError::InvalidState(
"floating window ownership is inconsistent",
@@ -1046,7 +1152,9 @@ impl WorkspaceSet {
.last_focused
.is_some_and(|window| self.window_workspaces.get(&window) != Some(id))
{
return Err(WorkspaceError::InvalidState("workspace focus is inconsistent"));
return Err(WorkspaceError::InvalidState(
"workspace focus is inconsistent",
));
}
if workspace
@@ -1059,8 +1167,12 @@ impl WorkspaceSet {
}
}
if seen_windows.len() != self.window_workspaces.len() || self.placements.len() != self.window_workspaces.len() {
return Err(WorkspaceError::InvalidState("window index retains stale entries"));
if seen_windows.len() != self.window_workspaces.len()
|| self.placements.len() != self.window_workspaces.len()
{
return Err(WorkspaceError::InvalidState(
"window index retains stale entries",
));
}
Ok(())
@@ -1068,10 +1180,13 @@ impl WorkspaceSet {
}
fn first_window(workspace: &Workspace) -> Option<WindowId> {
workspace
.layout
.preferred_window()
.or_else(|| workspace.floating.iter().copied().min_by_key(|window| window.0))
workspace.layout.preferred_window().or_else(|| {
workspace
.floating
.iter()
.copied()
.min_by_key(|window| window.0)
})
}
fn tiled_focus(workspace: &Workspace) -> Option<WindowId> {
@@ -1110,7 +1225,9 @@ mod tests {
fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=3 {
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
let WorkspaceLayout::Scrolling(layout) = &mut workspaces.active_mut().layout else {
panic!()
@@ -1149,18 +1266,30 @@ mod tests {
#[test]
fn reconfiguration_preserves_windows_and_current_widths_but_updates_defaults() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let workspace = workspaces.active_id();
workspaces.reconfigure_defaults(LayoutMode::Tree, ColumnWidth::Full, ViewportFocusStrategy::Minimal);
workspaces.reconfigure_defaults(
LayoutMode::Tree,
ColumnWidth::Full,
ViewportFocusStrategy::Minimal,
);
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!("existing layout must survive");
};
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
assert_eq!(layout.default_width(), ColumnWidth::Full);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
assert_eq!(
workspaces.workspace_for_window(WindowId(1)),
Some(workspace)
);
workspaces.switch_to_fixture(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
assert_eq!(
workspaces.workspace_for_window(WindowId(1)),
Some(workspace)
);
}
use super::*;
@@ -1179,9 +1308,14 @@ mod tests {
#[test]
fn configured_layout_defaults_apply_to_new_workspaces() {
let mut workspaces =
WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Full, ViewportFocusStrategy::Minimal);
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let mut workspaces = WorkspaceSet::new(
LayoutMode::Scrolling,
ColumnWidth::Full,
ViewportFocusStrategy::Minimal,
);
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let first = workspaces
.active_mut()
.layout
@@ -1193,7 +1327,10 @@ mod tests {
)
.unwrap();
assert_eq!(first.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 992.0, 792.0));
assert_eq!(
first.geometry[&WindowId(1)],
Rect::new(4.0, 4.0, 992.0, 792.0)
);
workspaces.switch_to_fixture(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
}
@@ -1201,10 +1338,14 @@ mod tests {
#[test]
fn switching_restores_workspace_focus() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(workspaces.switch_to_fixture(2).unwrap(), None);
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(workspaces.switch_to_fixture(1).unwrap(), Some(WindowId(1)));
assert_eq!(workspaces.switch_to_fixture(2).unwrap(), Some(WindowId(2)));
@@ -1214,8 +1355,12 @@ mod tests {
#[test]
fn moving_a_window_updates_membership_without_switching() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
let first = workspaces.active_id();
let second = workspaces
.move_window_to_fixture(WindowId(2), 2, Axis::Horizontal, 0.5)
@@ -1223,16 +1368,32 @@ mod tests {
assert_eq!(workspaces.active_id(), first);
assert_eq!(workspaces.workspace_for_window(WindowId(2)), Some(second));
assert!(!workspaces.workspace(first).unwrap().layout.contains(WindowId(2)));
assert!(workspaces.workspace(second).unwrap().layout.contains(WindowId(2)));
assert!(
!workspaces
.workspace(first)
.unwrap()
.layout
.contains(WindowId(2))
);
assert!(
workspaces
.workspace(second)
.unwrap()
.layout
.contains(WindowId(2))
);
assert!(workspaces.validate().is_ok());
}
#[test]
fn removing_focus_chooses_a_deterministic_remaining_window() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.remove_window(WindowId(1)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
@@ -1243,7 +1404,9 @@ mod tests {
fn closing_a_scrolling_column_focuses_its_adjacent_column() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=4 {
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
workspaces.focus_window(WindowId(3)).unwrap();
@@ -1255,7 +1418,9 @@ mod tests {
#[test]
fn focus_rejects_a_window_on_an_inactive_workspace() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.move_window_to_fixture(WindowId(1), 2, Axis::Horizontal, 0.5)
.unwrap();
@@ -1272,7 +1437,9 @@ mod tests {
#[test]
fn floating_toggle_removes_and_restores_tiled_membership() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let rect = Rect::new(10.0, 20.0, 640.0, 480.0);
assert_eq!(
@@ -1298,14 +1465,21 @@ mod tests {
#[test]
fn floating_geometry_updates_are_authoritative_and_validated() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(
workspaces.set_floating_rect(WindowId(1), Rect::default()),
Err(WorkspaceError::InvalidState("window is not floating"))
);
workspaces
.toggle_floating(WindowId(1), Rect::new(10.0, 20.0, 640.0, 480.0), Axis::Horizontal, 0.5)
.toggle_floating(
WindowId(1),
Rect::new(10.0, 20.0, 640.0, 480.0),
Axis::Horizontal,
0.5,
)
.unwrap();
workspaces
.set_floating_rect(WindowId(1), Rect::new(30.0, 40.0, f64::NAN, -2.0))
@@ -1327,9 +1501,15 @@ mod tests {
ViewportFocusStrategy::Center,
ViewportFocusStrategy::Paged,
] {
let mut workspaces = WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Proportion(0.5), strategy);
let mut workspaces = WorkspaceSet::new(
LayoutMode::Scrolling,
ColumnWidth::Proportion(0.5),
strategy,
);
for id in 1..=3 {
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0);
let gaps = GapConfig {
@@ -1379,13 +1559,20 @@ mod tests {
ViewportFocusStrategy::Paged,
);
for id in 1..=4 {
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0);
let before = workspaces
.active_mut()
.layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(4)))
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(4)),
)
.unwrap();
let scroll = workspaces.active().layout.viewport_x();
workspaces
@@ -1399,7 +1586,12 @@ mod tests {
let after = workspaces
.active_mut()
.layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(5)))
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(5)),
)
.unwrap();
assert_eq!(before.geometry, after.geometry);
assert_eq!(scroll, workspaces.active().layout.viewport_x());
@@ -1410,7 +1602,9 @@ mod tests {
#[test]
fn floating_window_can_follow_its_parent_workspace_without_focus() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let parent_workspace = workspaces.active_id();
workspaces.switch_to_fixture(2).unwrap();
workspaces
@@ -1422,7 +1616,10 @@ mod tests {
)
.unwrap();
assert_eq!(workspaces.workspace_for_window(WindowId(2)), Some(parent_workspace));
assert_eq!(
workspaces.workspace_for_window(WindowId(2)),
Some(parent_workspace)
);
assert_eq!(
workspaces.workspace(parent_workspace).unwrap().last_focused,
Some(WindowId(1))
@@ -1433,7 +1630,9 @@ mod tests {
#[test]
fn fullscreen_preserves_underlying_placement() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let before = workspaces.placement(WindowId(1));
assert!(workspaces.toggle_fullscreen(WindowId(1)).unwrap());
@@ -1451,9 +1650,13 @@ mod tests {
#[test]
fn mapping_under_fullscreen_does_not_change_focus() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.toggle_fullscreen(WindowId(1)).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(1)));
assert_eq!(workspaces.active().fullscreen, Some(WindowId(1)));
@@ -1465,10 +1668,17 @@ mod tests {
fn floating_dialog_can_focus_above_fullscreen() {
let mut workspaces = WorkspaceSet::default();
let workspace = workspaces.active_id();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.toggle_fullscreen(WindowId(1)).unwrap();
workspaces
.insert_floating_window(WindowId(2), workspace, Rect::new(100.0, 100.0, 500.0, 400.0), true)
.insert_floating_window(
WindowId(2),
workspace,
Rect::new(100.0, 100.0, 500.0, 400.0),
true,
)
.unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
@@ -1482,10 +1692,14 @@ mod tests {
#[test]
fn switching_to_fullscreen_restores_the_visible_window() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.toggle_fullscreen(WindowId(1)).unwrap();
workspaces.switch_to_fixture(2).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.move_window_to_fixture(WindowId(2), 1, Axis::Horizontal, 0.5)
.unwrap();
@@ -1494,7 +1708,9 @@ mod tests {
assert_eq!(workspaces.active().last_focused, Some(WindowId(1)));
assert_eq!(
workspaces.focus_window(WindowId(2)),
Err(WorkspaceError::InvalidState("focused window is hidden by fullscreen"))
Err(WorkspaceError::InvalidState(
"focused window is hidden by fullscreen"
))
);
assert!(workspaces.validate().is_ok());
}
@@ -1502,8 +1718,12 @@ mod tests {
#[test]
fn grouping_tiled_windows_preserves_workspace_membership() {
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces.stack_window(WindowId(2), WindowId(1)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
@@ -1524,16 +1744,26 @@ mod tests {
fn layout_mode_conversion_preserves_membership_and_focus() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=4 {
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
workspaces.focus_window(WindowId(2)).unwrap();
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
assert!(workspaces.set_active_layout_mode(LayoutMode::Tree, bounds).unwrap());
assert!(
workspaces
.set_active_layout_mode(LayoutMode::Tree, bounds)
.unwrap()
);
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert_eq!(
workspaces.active().layout.window_ids().collect::<HashSet<_>>(),
workspaces
.active()
.layout
.window_ids()
.collect::<HashSet<_>>(),
HashSet::from([WindowId(1), WindowId(2), WindowId(3), WindowId(4)])
);
@@ -1553,9 +1783,13 @@ mod tests {
ColumnWidth::Proportion(0.5),
ViewportFocusStrategy::Minimal,
);
workspaces.insert_window(ids[0], Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(ids[0], Axis::Horizontal, 0.5)
.unwrap();
for pair in ids.windows(2) {
workspaces.insert_window(pair[1], Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(pair[1], Axis::Horizontal, 0.5)
.unwrap();
workspaces.stack_window(pair[1], pair[0]).unwrap();
}
workspaces
@@ -1564,7 +1798,13 @@ mod tests {
#[test]
fn stacked_tree_mode_conversions_preserve_membership_focus_and_order() {
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let all_ids = [WindowId(30), WindowId(10), WindowId(20), WindowId(5), WindowId(40)];
let all_ids = [
WindowId(30),
WindowId(10),
WindowId(20),
WindowId(5),
WindowId(40),
];
for count in [2, 3, 5] {
let ids = &all_ids[..count];
let expected = ids.iter().copied().collect::<HashSet<_>>();
@@ -1583,7 +1823,11 @@ mod tests {
assert_eq!(workspaces.active().last_focused, Some(*focused));
assert!(workspaces.validate().is_ok());
for mode in [LayoutMode::Scrolling, LayoutMode::Tree, LayoutMode::Scrolling] {
for mode in [
LayoutMode::Scrolling,
LayoutMode::Tree,
LayoutMode::Scrolling,
] {
assert!(workspaces.set_active_layout_mode(mode, bounds).unwrap());
let windows = workspaces.active().layout.window_ids().collect::<Vec<_>>();
assert_eq!(windows.len(), ids.len());
@@ -1605,15 +1849,29 @@ mod tests {
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let mut workspaces = tree_stack_workspaces(&[WindowId(30), WindowId(10), WindowId(20)]);
workspaces.focus_window(WindowId(10)).unwrap();
workspaces.insert_window(WindowId(5), Axis::Vertical, 0.5).unwrap();
workspaces.insert_window(WindowId(40), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(50), Axis::Vertical, 0.5).unwrap();
workspaces
.insert_window(WindowId(5), Axis::Vertical, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(40), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(50), Axis::Vertical, 0.5)
.unwrap();
workspaces.stack_window(WindowId(50), WindowId(40)).unwrap();
workspaces.focus_window(WindowId(40)).unwrap();
let before = workspaces.active().layout.window_ids().collect::<HashSet<_>>();
let before = workspaces
.active()
.layout
.window_ids()
.collect::<HashSet<_>>();
assert_eq!(before.len(), 6);
assert!(workspaces.active().layout.geometry(bounds).unwrap().len() < before.len());
for mode in [LayoutMode::Scrolling, LayoutMode::Tree, LayoutMode::Scrolling] {
for mode in [
LayoutMode::Scrolling,
LayoutMode::Tree,
LayoutMode::Scrolling,
] {
assert!(workspaces.set_active_layout_mode(mode, bounds).unwrap());
let windows = workspaces.active().layout.window_ids().collect::<Vec<_>>();
assert_eq!(windows.len(), before.len());
@@ -1631,11 +1889,19 @@ mod tests {
for _ in 0..32 {
let mut workspaces = tree_stack_workspaces(&[WindowId(30), WindowId(20), WindowId(40)]);
workspaces.active_mut().last_focused = None;
assert_eq!(workspaces.active().layout.preferred_window(), Some(WindowId(40)));
workspaces.insert_window(WindowId(10), Axis::Horizontal, 0.5).unwrap();
assert_eq!(
workspaces.active().layout.preferred_window(),
Some(WindowId(40))
);
workspaces
.insert_window(WindowId(10), Axis::Horizontal, 0.5)
.unwrap();
workspaces.remove_window(WindowId(10)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(40)));
assert_eq!(workspaces.active().layout.preferred_window(), Some(WindowId(40)));
assert_eq!(
workspaces.active().layout.preferred_window(),
Some(WindowId(40))
);
assert!(workspaces.validate().is_ok());
}
}
@@ -1644,14 +1910,19 @@ mod tests {
fn scrolling_column_commands_preserve_workspace_state() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=3 {
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
workspaces.stack_window(WindowId(3), WindowId(2)).unwrap();
assert!(workspaces.extract_window(WindowId(3)).unwrap());
assert!(
workspaces
.cycle_column_width(WindowId(3), &[ColumnWidth::Proportion(0.5), ColumnWidth::Full],)
.cycle_column_width(
WindowId(3),
&[ColumnWidth::Proportion(0.5), ColumnWidth::Full],
)
.unwrap()
);
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
@@ -1679,7 +1950,9 @@ mod tests {
0 if windows.len() < 48 => {
let window = WindowId(next_window);
next_window += 1;
workspaces.insert_window(window, random_axis(random), 0.5).unwrap();
workspaces
.insert_window(window, random_axis(random), 0.5)
.unwrap();
windows.push(window);
}
1 if !windows.is_empty() => {
@@ -1726,7 +1999,8 @@ mod tests {
})
.collect::<Vec<_>>();
if let Some(window) = candidates.get(random as usize % candidates.len().max(1)) {
if let Some(window) = candidates.get(random as usize % candidates.len().max(1))
{
workspaces.focus_window(*window).unwrap();
}
}
@@ -1774,7 +2048,9 @@ mod tab_geometry_tests {
)
.unwrap();
}
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
@@ -1785,9 +2061,13 @@ mod tab_geometry_tests {
fn default_column_display_only_changes_future_columns_and_reaches_new_workspaces() {
use ferese_layout::ColumnDisplay;
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
@@ -1795,13 +2075,17 @@ mod tab_geometry_tests {
assert_eq!(layout.columns()[1].display, ColumnDisplay::Tabbed);
let next = workspaces.create_workspace();
workspaces.activate(next).unwrap();
workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(3), Axis::Horizontal, 0.5)
.unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
workspaces.set_default_column_display(ColumnDisplay::Stacked);
workspaces.insert_window(WindowId(4), Axis::Horizontal, 0.5).unwrap();
workspaces
.insert_window(WindowId(4), Axis::Horizontal, 0.5)
.unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
@@ -1812,7 +2096,9 @@ mod tab_geometry_tests {
#[test]
fn tab_header_reserves_space_and_disappears_after_leaving() {
let mut layout = WorkspaceLayout::Tree(LayoutTree::default());
layout.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
layout
.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
layout
.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
+74 -21
View File
@@ -56,13 +56,22 @@ impl OutputWorkspaceMap {
.filter(|id| workspaces.workspace(*id).is_some())
}
pub fn ordered_workspaces(&self, workspaces: &WorkspaceSet, output: OutputId) -> Vec<WorkspaceId> {
pub fn ordered_workspaces(
&self,
workspaces: &WorkspaceSet,
output: OutputId,
) -> Vec<WorkspaceId> {
self.ordered_workspace_ids(workspaces, output).collect()
}
/// Out-of-range numeric selection resolves to the trailing spare. Looking
/// up a gesture target never creates workspaces or changes history.
pub fn workspace_at(&self, workspaces: &WorkspaceSet, output: OutputId, index: u32) -> Option<WorkspaceId> {
pub fn workspace_at(
&self,
workspaces: &WorkspaceSet,
output: OutputId,
index: u32,
) -> Option<WorkspaceId> {
let index = index.checked_sub(1)? as usize;
self.ordered_workspace_ids(workspaces, output)
.nth(index)
@@ -76,8 +85,13 @@ impl OutputWorkspaceMap {
workspaces: &mut WorkspaceSet,
transitions: &HashSet<WorkspaceId>,
) -> Vec<WorkspaceId> {
if let Some(active) = self.focused_output().and_then(|output| self.active_workspace(output)) {
workspaces.activate(active).expect("output's active workspace exists");
if let Some(active) = self
.focused_output()
.and_then(|output| self.active_workspace(output))
{
workspaces
.activate(active)
.expect("output's active workspace exists");
}
let mut protected = HashSet::new();
@@ -129,7 +143,11 @@ mod tests {
let mut outputs = OutputWorkspaceMap::default();
let output = OutputId(1);
outputs
.connect(output, OutputGeometry::new(0, 0, 1000, 800), set.active_id())
.connect(
output,
OutputGeometry::new(0, 0, 1000, 800),
set.active_id(),
)
.unwrap();
(set, outputs, output)
@@ -142,7 +160,10 @@ mod tests {
for output in outputs.connected_outputs() {
let ids = outputs.ordered_workspaces(set, output);
assert!(set.workspace(*ids.last().unwrap()).unwrap().is_empty());
let empties = ids.iter().filter(|id| set.workspace(**id).unwrap().is_empty()).count();
let empties = ids
.iter()
.filter(|id| set.workspace(**id).unwrap().is_empty())
.count();
let active_middle_empty = outputs
.active_workspace(output)
.is_some_and(|id| Some(&id) != ids.last() && set.workspace(id).unwrap().is_empty());
@@ -156,12 +177,14 @@ mod tests {
let first = set.active_id();
reconcile(&mut set, &mut outputs);
assert_eq!(set.iter().count(), 1);
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
reconcile(&mut set, &mut outputs);
let second = outputs.workspace_at(&set, output, 2).unwrap();
outputs.switch_workspace(output, second).unwrap();
reconcile(&mut set, &mut outputs);
set.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
reconcile(&mut set, &mut outputs);
let spare = outputs.workspace_at(&set, output, 3).unwrap();
@@ -187,7 +210,8 @@ mod tests {
fn selected_empty_survives_until_leaving_but_history_does_not_pin_it() {
let (mut set, mut outputs, output) = setup();
let first = set.active_id();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
reconcile(&mut set, &mut outputs);
let spare = outputs.workspace_at(&set, output, 2).unwrap();
set.remove_window(WindowId(1)).unwrap();
@@ -205,7 +229,8 @@ mod tests {
fn transitions_temporarily_protect_empty_workspaces_and_cancelled_lookup_has_no_effect() {
let (mut set, mut outputs, output) = setup();
let first = set.active_id();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
reconcile(&mut set, &mut outputs);
let spare = outputs.workspace_at(&set, output, 2).unwrap();
let before = outputs.workspace_views(&set);
@@ -246,7 +271,8 @@ mod tests {
fn monitors_have_independent_positions_and_reconnect_restores_occupied_workspaces() {
let (mut set, mut outputs, left) = setup();
let first = set.active_id();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
reconcile(&mut set, &mut outputs);
let right = OutputId(2);
let second = set.create_workspace();
@@ -255,16 +281,25 @@ mod tests {
.unwrap();
outputs.focus_output(right).unwrap();
reconcile(&mut set, &mut outputs);
set.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(outputs.workspace_at(&set, left, 1), Some(first));
assert_eq!(outputs.workspace_at(&set, right, 1), Some(second));
assert_eq!(
outputs.workspace_views(&set).iter().filter(|view| view.visible).count(),
outputs
.workspace_views(&set)
.iter()
.filter(|view| view.visible)
.count(),
2
);
assert_eq!(
outputs.workspace_views(&set).iter().filter(|view| view.focused).count(),
outputs
.workspace_views(&set)
.iter()
.filter(|view| view.focused)
.count(),
1
);
@@ -295,7 +330,11 @@ mod sequences {
let mut set = WorkspaceSet::default();
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), OutputGeometry::new(0, 0, 1000, 800), set.active_id())
.connect(
OutputId(1),
OutputGeometry::new(0, 0, 1000, 800),
set.active_id(),
)
.unwrap();
let mut next_window = 1;
let mut seed = 0x1234_5678_u64;
@@ -313,11 +352,13 @@ mod sequences {
match seed % 5 {
0 => {
set.insert_window(WindowId(next_window), Axis::Horizontal, 0.5).unwrap();
set.insert_window(WindowId(next_window), Axis::Horizontal, 0.5)
.unwrap();
next_window += 1;
}
1 if !windows.is_empty() => {
set.remove_window(windows[seed as usize % windows.len()]).unwrap();
set.remove_window(windows[seed as usize % windows.len()])
.unwrap();
}
2 => {
let index = (seed % 9 + 1) as u32;
@@ -326,8 +367,13 @@ mod sequences {
}
3 if !windows.is_empty() => {
let target = outputs.workspace_at(&set, output, u32::MAX).unwrap();
set.move_window_to_workspace(windows[seed as usize % windows.len()], target, Axis::Horizontal, 0.5)
.unwrap();
set.move_window_to_workspace(
windows[seed as usize % windows.len()],
target,
Axis::Horizontal,
0.5,
)
.unwrap();
}
_ => {
if outputs.active_workspace(OutputId(2)).is_some() {
@@ -335,7 +381,11 @@ mod sequences {
} else {
let fallback = set.create_workspace();
outputs
.connect(OutputId(2), OutputGeometry::new(1000, 0, 1000, 800), fallback)
.connect(
OutputId(2),
OutputGeometry::new(1000, 0, 1000, 800),
fallback,
)
.unwrap();
outputs.focus_output(OutputId(2)).unwrap();
}
@@ -348,7 +398,10 @@ mod sequences {
assert_eq!(views.iter().filter(|view| view.focused).count(), 1);
assert_eq!(views.len(), set.iter().count());
for output in outputs.connected_outputs() {
let rows = views.iter().filter(|view| view.output == output).collect::<Vec<_>>();
let rows = views
.iter()
.filter(|view| view.output == output)
.collect::<Vec<_>>();
assert_eq!(rows.last().unwrap().window_count, 0);
for (index, row) in rows.iter().enumerate() {
assert_eq!(row.index, index as u32 + 1);
+4 -1
View File
@@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize};
pub const VERSION: u32 = 1;
pub fn now_ms() -> u64 {
let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 };
let mut time = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: time is a correctly sized writable timespec. CLOCK_BOOTTIME
// includes suspend, unlike the renderer's animation clock.
if unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &raw mut time) } != 0 {
+20 -6
View File
@@ -40,7 +40,8 @@ impl Connection {
command: command.into(),
args,
};
let response = write_frame(&mut self.stream, &request).and_then(|()| read_frame::<Response>(&mut self.stream));
let response = write_frame(&mut self.stream, &request)
.and_then(|()| read_frame::<Response>(&mut self.stream));
let response = match response {
Ok(response) => response,
Err(error) => {
@@ -60,7 +61,9 @@ impl Connection {
/// Wait for a server-side change; dropping the cancellation handle interrupts it.
pub fn wait(&mut self, command: &str, args: Value) -> Result<Value, String> {
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
self.stream
.set_read_timeout(None)
.map_err(|e| e.to_string())?;
let result = self.call(command, args);
self.stream
.set_read_timeout(Some(Duration::from_secs(2)))
@@ -93,12 +96,19 @@ mod tests {
send.send(connection.wait("activity-watch", json!({"since": 7})))
.unwrap();
});
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
server
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let request: Request = read_frame(&mut server).unwrap();
assert_eq!(request.command, "activity-watch");
assert_eq!(request.args, json!({"since": 7}));
drop(cancellation);
assert!(receive.recv_timeout(Duration::from_secs(2)).unwrap().is_err());
assert!(
receive
.recv_timeout(Duration::from_secs(2))
.unwrap()
.is_err()
);
assert!(read_frame::<Request>(&mut server).is_err());
worker.join().unwrap();
}
@@ -107,8 +117,12 @@ mod tests {
fn response_identity_is_checked_before_returning_values() {
for wrong_version in [false, true] {
let (stream, mut server) = UnixStream::pair().unwrap();
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
server
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let worker = std::thread::spawn(move || {
let request: Request = read_frame(&mut server).unwrap();
let mut response = Response::success(request.id, json!({}));
+4 -1
View File
@@ -79,7 +79,10 @@ 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::Oversized(size) => write!(
formatter,
"IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}"
),
Self::InvalidJson(error) => write!(formatter, "invalid IPC JSON: {error}"),
}
}
+8 -2
View File
@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
pub fn now_us() -> u64 {
let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 };
let mut time = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// CLOCK_MONOTONIC shares one epoch between the compositor and shell.
if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut time) } != 0 {
return 0;
@@ -100,7 +103,10 @@ impl Player {
}
let position = position.max(0.0) as u64;
Some(self.length_us.map_or(position, |length| position.min(length)))
Some(
self.length_us
.map_or(position, |length| position.min(length)),
)
}
}
+17 -4
View File
@@ -16,10 +16,17 @@ pub fn runtime_default() -> io::Result<PathBuf> {
resolve_with(None, None, std::env::var_os("XDG_RUNTIME_DIR").as_deref())
}
fn resolve_with(explicit: Option<&Path>, socket: Option<&OsStr>, runtime: Option<&OsStr>) -> io::Result<PathBuf> {
fn resolve_with(
explicit: Option<&Path>,
socket: Option<&OsStr>,
runtime: Option<&OsStr>,
) -> io::Result<PathBuf> {
if let Some(path) = explicit {
if path.as_os_str().is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "IPC socket path is empty"));
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"IPC socket path is empty",
));
}
return Ok(path.to_owned());
}
@@ -41,8 +48,14 @@ mod tests {
let runtime = Some(OsStr::new("/run/user/1000"));
let host = OsStr::new("/run/user/1000/ferese/control.sock");
let nested = Path::new("/tmp/nested/ferese/control.sock");
assert_eq!(resolve_with(Some(nested), Some(host), runtime).unwrap(), nested);
assert_eq!(resolve_with(None, Some(nested.as_os_str()), runtime).unwrap(), nested);
assert_eq!(
resolve_with(Some(nested), Some(host), runtime).unwrap(),
nested
);
assert_eq!(
resolve_with(None, Some(nested.as_os_str()), runtime).unwrap(),
nested
);
assert_eq!(resolve_with(None, None, runtime).unwrap(), Path::new(host));
assert_eq!(
resolve_with(None, Some(OsStr::new("")), runtime).unwrap(),
+12 -3
View File
@@ -22,7 +22,10 @@ pub struct Snapshot {
impl Snapshot {
/// Decode older messages with the caller's catalog. Explicit empty catalogs stay empty.
pub fn decode(mut value: Value, fallback_families: impl FnOnce() -> Vec<Family>) -> Result<Self, String> {
pub fn decode(
mut value: Value,
fallback_families: impl FnOnce() -> Vec<Family>,
) -> Result<Self, String> {
if let Some(object) = value.as_object_mut()
&& !object.contains_key("families")
{
@@ -40,7 +43,10 @@ impl Snapshot {
}
impl Connection {
pub fn get(&mut self, fallback_families: impl FnOnce() -> Vec<Family>) -> Result<Snapshot, String> {
pub fn get(
&mut self,
fallback_families: impl FnOnce() -> Vec<Family>,
) -> Result<Snapshot, String> {
Snapshot::decode(self.call("theme-get", json!({}))?, fallback_families)
}
@@ -49,6 +55,9 @@ impl Connection {
revision: u64,
fallback_families: impl FnOnce() -> Vec<Family>,
) -> Result<Snapshot, String> {
Snapshot::decode(self.wait("theme-watch", json!({"since": revision}))?, fallback_families)
Snapshot::decode(
self.wait("theme-watch", json!({"since": revision}))?,
fallback_families,
)
}
}
+4 -1
View File
@@ -89,7 +89,10 @@ mod tests {
] {
let encoded = serde_json::to_value(readiness).unwrap();
assert_eq!(encoded, json!(name));
assert_eq!(serde_json::from_value::<Readiness>(encoded).unwrap(), readiness);
assert_eq!(
serde_json::from_value::<Readiness>(encoded).unwrap(),
readiness
);
}
}
+243 -68
View File
@@ -4,7 +4,9 @@ use std::fmt;
mod scrolling;
pub use scrolling::{Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
pub use scrolling::{
Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget,
};
pub const TAB_BAR_HEIGHT: f64 = 24.0;
@@ -68,7 +70,12 @@ pub struct Rect {
impl Rect {
pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self { x, y, width, height }
Self {
x,
y,
width,
height,
}
}
fn center(self) -> (f64, f64) {
@@ -184,8 +191,12 @@ impl LayoutTree {
fn count(tree: &LayoutTree, node: NodeId) -> usize {
match tree.nodes.get(&node) {
Some(Node::Window(_)) => 1,
Some(Node::Split { first, second, .. }) => count(tree, *first) + count(tree, *second),
Some(Node::Stack { children, active }) => children.get(*active).map_or(0, |child| count(tree, *child)),
Some(Node::Split { first, second, .. }) => {
count(tree, *first) + count(tree, *second)
}
Some(Node::Stack { children, active }) => {
children.get(*active).map_or(0, |child| count(tree, *child))
}
None => 0,
}
}
@@ -208,7 +219,11 @@ impl LayoutTree {
if let Some(root) = self.root {
self.collect_window_positions(root, bounds, &mut windows)?;
}
windows.sort_by(|(_, left), (_, right)| left.x.total_cmp(&right.x).then_with(|| left.y.total_cmp(&right.y)));
windows.sort_by(|(_, left), (_, right)| {
left.x
.total_cmp(&right.x)
.then_with(|| left.y.total_cmp(&right.y))
});
Ok(windows.into_iter().map(|(window, _)| window).collect())
}
@@ -420,7 +435,11 @@ impl LayoutTree {
}
pub fn tab_groups(&self) -> Vec<TabGroup> {
let mut groups: Vec<_> = self.nodes.keys().filter_map(|id| self.group_at(*id)).collect();
let mut groups: Vec<_> = self
.nodes
.keys()
.filter_map(|id| self.group_at(*id))
.collect();
groups.sort_by_key(|group| group.windows[0].0);
groups
}
@@ -482,7 +501,11 @@ impl LayoutTree {
Ok(geometry)
}
pub fn geometry_with_gaps(&self, bounds: Rect, gaps: GapConfig) -> Result<HashMap<WindowId, Rect>, LayoutError> {
pub fn geometry_with_gaps(
&self,
bounds: Rect,
gaps: GapConfig,
) -> Result<HashMap<WindowId, Rect>, LayoutError> {
let mut geometry = self.geometry(bounds)?;
apply_gaps(&mut geometry, bounds, gaps);
@@ -547,7 +570,11 @@ impl LayoutTree {
Ok(result)
}
pub fn automatic_axis(&self, focused: Option<WindowId>, bounds: Rect) -> Result<Axis, LayoutError> {
pub fn automatic_axis(
&self,
focused: Option<WindowId>,
bounds: Rect,
) -> Result<Axis, LayoutError> {
let Some(root) = self.root else {
return Ok(Axis::Horizontal);
};
@@ -625,7 +652,12 @@ impl LayoutTree {
.map(|(candidate, _, _, _)| candidate))
}
pub fn move_window(&mut self, window: WindowId, direction: Direction, bounds: Rect) -> Result<bool, LayoutError> {
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
bounds: Rect,
) -> Result<bool, LayoutError> {
let Some(neighbor) = self.directional_neighbor(window, direction, bounds)? else {
return Ok(false);
};
@@ -649,7 +681,12 @@ impl LayoutTree {
Ok(true)
}
pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result<bool, LayoutError> {
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
let mut child = self
.windows
.get(&window)
@@ -778,7 +815,9 @@ impl LayoutTree {
match self.nodes.get(&node) {
Some(Node::Window(_)) => Ok(node),
Some(Node::Split { first, .. }) => self.first_window(*first),
Some(Node::Stack { children, active, .. }) => children
Some(Node::Stack {
children, active, ..
}) => children
.get(*active)
.copied()
.ok_or(LayoutError::InvalidTree("stack active index is invalid"))
@@ -787,7 +826,12 @@ impl LayoutTree {
}
}
fn replace_child(&mut self, parent: NodeId, old: NodeId, new: NodeId) -> Result<(), LayoutError> {
fn replace_child(
&mut self,
parent: NodeId,
old: NodeId,
new: NodeId,
) -> Result<(), LayoutError> {
match self.nodes.get_mut(&parent) {
Some(Node::Split { first, .. }) if *first == old => *first = new,
Some(Node::Split { second, .. }) if *second == old => *second = new,
@@ -804,7 +848,12 @@ impl LayoutTree {
Ok(())
}
fn collapse_parent(&mut self, parent: NodeId, leaf: NodeId, sibling: NodeId) -> Result<(), LayoutError> {
fn collapse_parent(
&mut self,
parent: NodeId,
leaf: NodeId,
sibling: NodeId,
) -> Result<(), LayoutError> {
let window = match self.nodes.get(&leaf) {
Some(Node::Window(window)) => *window,
_ => return Err(LayoutError::InvalidTree("removed leaf is not a window")),
@@ -901,11 +950,21 @@ impl LayoutTree {
} else {
(available - second_required).max(0.0)
};
let adjusted_ratio = if available > 0.0 { first_extent / available } else { 0.5 };
let adjusted_ratio = if available > 0.0 {
first_extent / available
} else {
0.5
};
let (first_bounds, second_bounds) = split_rect(bounds, *axis, adjusted_ratio);
self.layout_node_constrained(*first, first_bounds, constraints, focused, geometry)?;
self.layout_node_constrained(*second, second_bounds, constraints, focused, geometry)?;
self.layout_node_constrained(
*second,
second_bounds,
constraints,
focused,
geometry,
)?;
}
Some(Node::Stack { children, active }) => {
let child = children
@@ -931,7 +990,10 @@ impl LayoutTree {
Ok((constraint.min_width, constraint.min_height))
}
Some(Node::Split {
axis, first, second, ..
axis,
first,
second,
..
}) => {
let first = self.minimum_size(*first, constraints)?;
let second = self.minimum_size(*second, constraints)?;
@@ -962,7 +1024,9 @@ impl LayoutTree {
Some(Node::Split { first, second, .. }) => {
self.contains_window(*first, window) || self.contains_window(*second, window)
}
Some(Node::Stack { children, .. }) => children.iter().any(|child| self.contains_window(*child, window)),
Some(Node::Stack { children, .. }) => children
.iter()
.any(|child| self.contains_window(*child, window)),
None => false,
}
}
@@ -987,9 +1051,15 @@ impl LayoutTree {
}
}
Some(Node::Split {
ratio, first, second, ..
ratio,
first,
second,
..
}) => {
if first == second || !ratio.is_finite() || !(MIN_SPLIT_RATIO..=MAX_SPLIT_RATIO).contains(ratio) {
if first == second
|| !ratio.is_finite()
|| !(MIN_SPLIT_RATIO..=MAX_SPLIT_RATIO).contains(ratio)
{
return Err(LayoutError::InvalidTree("split is invalid"));
}
@@ -1025,7 +1095,11 @@ fn valid_gap(gap: f64) -> f64 {
}
fn valid_resize_amount(amount: f64) -> f64 {
if amount.is_finite() { amount.abs() } else { 0.0 }
if amount.is_finite() {
amount.abs()
} else {
0.0
}
}
fn normalized_constraints(constraints: SizeConstraints) -> SizeConstraints {
@@ -1119,7 +1193,12 @@ fn nearly_equal(left: f64, right: f64) -> bool {
(left - right).abs() < f64::EPSILON * left.abs().max(right.abs()).max(1.0)
}
fn intervals_overlap(first_start: f64, first_size: f64, second_start: f64, second_size: f64) -> bool {
fn intervals_overlap(
first_start: f64,
first_size: f64,
second_start: f64,
second_size: f64,
) -> bool {
first_start < second_start + second_size && second_start < first_start + first_size
}
@@ -1128,14 +1207,24 @@ fn split_rect(bounds: Rect, axis: Axis, ratio: f64) -> (Rect, Rect) {
Axis::Horizontal => {
let boundary = bounds.x + bounds.width * ratio;
let first = Rect::new(bounds.x, bounds.y, boundary - bounds.x, bounds.height);
let second = Rect::new(boundary, bounds.y, bounds.x + bounds.width - boundary, bounds.height);
let second = Rect::new(
boundary,
bounds.y,
bounds.x + bounds.width - boundary,
bounds.height,
);
(first, second)
}
Axis::Vertical => {
let boundary = bounds.y + bounds.height * ratio;
let first = Rect::new(bounds.x, bounds.y, bounds.width, boundary - bounds.y);
let second = Rect::new(bounds.x, boundary, bounds.width, bounds.y + bounds.height - boundary);
let second = Rect::new(
bounds.x,
boundary,
bounds.width,
bounds.y + bounds.height - boundary,
);
(first, second)
}
@@ -1149,32 +1238,43 @@ mod tests {
#[test]
fn insertion_splits_the_focused_leaf() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.5)
.unwrap();
assert!(tree.validate().is_ok());
assert_eq!(tree.geometry(Rect::new(0.0, 0.0, 100.0, 80.0)).unwrap().len(), 3);
assert_eq!(
tree.geometry(Rect::new(0.0, 0.0, 100.0, 80.0))
.unwrap()
.len(),
3
);
}
#[test]
fn removal_collapses_the_parent_split() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.remove(WindowId(1)).unwrap();
assert!(tree.validate().is_ok());
assert_eq!(tree.node(tree.root().unwrap()), Some(&Node::Window(WindowId(2))));
assert_eq!(
tree.node(tree.root().unwrap()),
Some(&Node::Window(WindowId(2)))
);
}
#[test]
fn shared_split_boundary_is_exact() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 1.0 / 3.0).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 1.0 / 3.0)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 1.0 / 3.0)
.unwrap();
@@ -1189,7 +1289,8 @@ mod tests {
#[test]
fn rejects_duplicate_and_unknown_windows() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5),
@@ -1210,20 +1311,28 @@ mod tests {
#[test]
fn automatic_axis_follows_focused_tile_shape() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
let wide = Rect::new(0.0, 0.0, 1_920.0, 1_080.0);
assert_eq!(tree.automatic_axis(Some(WindowId(1)), wide).unwrap(), Axis::Horizontal);
assert_eq!(
tree.automatic_axis(Some(WindowId(1)), wide).unwrap(),
Axis::Horizontal
);
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(tree.automatic_axis(Some(WindowId(1)), wide).unwrap(), Axis::Vertical);
assert_eq!(
tree.automatic_axis(Some(WindowId(1)), wide).unwrap(),
Axis::Vertical
);
}
#[test]
fn gaps_preserve_outer_and_shared_spacing() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
@@ -1241,7 +1350,8 @@ mod tests {
#[test]
fn smart_gaps_remove_outer_gap_for_one_window() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
let geometry = tree
@@ -1260,7 +1370,8 @@ mod tests {
#[test]
fn default_gaps_keep_outer_spacing_for_one_window() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
let geometry = tree
.geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default())
@@ -1272,7 +1383,8 @@ mod tests {
#[test]
fn directional_neighbor_prefers_primary_distance() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(2)), Axis::Vertical, 0.5)
@@ -1285,11 +1397,13 @@ mod tests {
Some(WindowId(2))
);
assert_eq!(
tree.directional_neighbor(WindowId(2), Direction::Down, bounds).unwrap(),
tree.directional_neighbor(WindowId(2), Direction::Down, bounds)
.unwrap(),
Some(WindowId(3))
);
assert_eq!(
tree.directional_neighbor(WindowId(1), Direction::Left, bounds).unwrap(),
tree.directional_neighbor(WindowId(1), Direction::Left, bounds)
.unwrap(),
None
);
}
@@ -1297,12 +1411,16 @@ mod tests {
#[test]
fn directional_move_swaps_window_leaves() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
assert!(tree.move_window(WindowId(1), Direction::Right, bounds).unwrap());
assert!(
tree.move_window(WindowId(1), Direction::Right, bounds)
.unwrap()
);
let geometry = tree.geometry(bounds).unwrap();
assert_eq!(geometry[&WindowId(2)].x, 0.0);
@@ -1313,11 +1431,16 @@ mod tests {
#[test]
fn directional_move_at_boundary_is_a_noop() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
assert!(
!tree
.move_window(WindowId(1), Direction::Left, Rect::new(0.0, 0.0, 100.0, 80.0),)
.move_window(
WindowId(1),
Direction::Left,
Rect::new(0.0, 0.0, 100.0, 80.0),
)
.unwrap()
);
}
@@ -1325,14 +1448,18 @@ mod tests {
#[test]
fn directional_resize_changes_the_nearest_matching_split() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(tree.resize_window(WindowId(1), Direction::Right, 0.1).unwrap());
assert!(
tree.resize_window(WindowId(1), Direction::Right, 0.1)
.unwrap()
);
let geometry = tree.geometry(bounds).unwrap();
assert_eq!(geometry[&WindowId(1)].width, 30.0);
@@ -1344,15 +1471,23 @@ mod tests {
#[test]
fn directional_resize_walks_to_an_ancestor_and_clamps() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(tree.resize_window(WindowId(3), Direction::Right, 1.0).unwrap());
assert!(!tree.resize_window(WindowId(3), Direction::Right, 1.0).unwrap());
assert!(
tree.resize_window(WindowId(3), Direction::Right, 1.0)
.unwrap()
);
assert!(
!tree
.resize_window(WindowId(3), Direction::Right, 1.0)
.unwrap()
);
let geometry = tree.geometry(bounds).unwrap();
assert_eq!(geometry[&WindowId(2)].x, 95.0);
@@ -1362,16 +1497,26 @@ mod tests {
#[test]
fn directional_resize_at_an_outer_edge_is_a_noop() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
assert!(!tree.resize_window(WindowId(1), Direction::Left, 0.05).unwrap());
assert!(!tree.resize_window(WindowId(1), Direction::Right, f64::NAN).unwrap());
assert!(
!tree
.resize_window(WindowId(1), Direction::Left, 0.05)
.unwrap()
);
assert!(
!tree
.resize_window(WindowId(1), Direction::Right, f64::NAN)
.unwrap()
);
}
#[test]
fn stacking_windows_shows_only_the_active_child() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
@@ -1388,14 +1533,19 @@ mod tests {
let ids = [WindowId(30), WindowId(10), WindowId(20), WindowId(5)];
tree.insert(ids[0], None, Axis::Horizontal, 0.5).unwrap();
for pair in ids.windows(2) {
tree.insert(pair[1], Some(pair[0]), Axis::Horizontal, 0.5).unwrap();
tree.insert(pair[1], Some(pair[0]), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(pair[1], pair[0]).unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
for active in ids {
tree.activate_window(active).unwrap();
assert_eq!(
tree.geometry(bounds).unwrap().keys().copied().collect::<Vec<_>>(),
tree.geometry(bounds)
.unwrap()
.keys()
.copied()
.collect::<Vec<_>>(),
vec![active]
);
assert_eq!(tree.window_ids_in_reading_order(bounds).unwrap(), ids);
@@ -1434,7 +1584,8 @@ mod tests {
#[test]
fn structural_reading_order_retains_xy_order_through_splits_and_stacks() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(40), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(40), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(10), Some(WindowId(40)), Axis::Vertical, 0.5)
.unwrap();
tree.insert(WindowId(30), Some(WindowId(40)), Axis::Horizontal, 0.5)
@@ -1461,8 +1612,13 @@ mod tests {
fn tree_preference_follows_first_split_branch_and_active_stack_children() {
let mut tree = LayoutTree::default();
assert_eq!(tree.preferred_window(), None);
assert!(tree.window_ids_in_reading_order(Rect::default()).unwrap().is_empty());
tree.insert(WindowId(30), None, Axis::Horizontal, 0.5).unwrap();
assert!(
tree.window_ids_in_reading_order(Rect::default())
.unwrap()
.is_empty()
);
tree.insert(WindowId(30), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(10), Some(WindowId(30)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(20), Some(WindowId(30)), Axis::Vertical, 0.5)
@@ -1478,7 +1634,8 @@ mod tests {
#[test]
fn activating_a_hidden_stack_child_changes_visible_geometry() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
@@ -1493,7 +1650,8 @@ mod tests {
#[test]
fn removing_from_a_stack_collapses_or_preserves_cardinality() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
@@ -1503,7 +1661,10 @@ mod tests {
tree.remove(WindowId(3)).unwrap();
tree.remove(WindowId(2)).unwrap();
assert_eq!(tree.node(tree.root().unwrap()), Some(&Node::Window(WindowId(1))));
assert_eq!(
tree.node(tree.root().unwrap()),
Some(&Node::Window(WindowId(1)))
);
assert!(tree.validate().is_ok());
}
@@ -1516,7 +1677,9 @@ mod tests {
let bounds = Rect::new(0.0, 0.0, 1_920.0, 1_080.0);
for _ in 0..2_000 {
random = random.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
random = random
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
let operation = random % 6;
match operation {
@@ -1530,7 +1693,8 @@ mod tests {
Axis::Vertical
};
tree.insert(window, focused, axis, ratio_from_random(random)).unwrap();
tree.insert(window, focused, axis, ratio_from_random(random))
.unwrap();
windows.push(window);
}
1 if !windows.is_empty() => {
@@ -1557,7 +1721,8 @@ mod tests {
}
5 if !windows.is_empty() => {
let window = windows[random as usize % windows.len()];
tree.resize_window(window, random_direction(random), 0.03).unwrap();
tree.resize_window(window, random_direction(random), 0.03)
.unwrap();
}
_ => {}
}
@@ -1569,7 +1734,8 @@ mod tests {
#[test]
fn constraints_adjust_split_boundaries_when_space_is_available() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.2).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.2)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.2)
.unwrap();
let constraints = HashMap::from([
@@ -1609,7 +1775,8 @@ mod tests {
#[test]
fn focused_window_wins_an_impossible_minimum_constraint_conflict() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let constraints = HashMap::from([
@@ -1651,7 +1818,8 @@ mod tests {
#[test]
fn maximum_constraints_clip_without_overlapping_neighbors() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
let constraints = HashMap::from([(
WindowId(1),
SizeConstraints {
@@ -1669,7 +1837,10 @@ mod tests {
)
.unwrap();
assert_eq!(result.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 60.0, 40.0));
assert_eq!(
result.geometry[&WindowId(1)],
Rect::new(4.0, 4.0, 60.0, 40.0)
);
assert_eq!(result.warnings.len(), 2);
}
@@ -1697,7 +1868,8 @@ mod tab_tests {
fn nested() -> LayoutTree {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.6)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.4)
@@ -1727,7 +1899,10 @@ mod tab_tests {
assert_eq!(tree.adjacent_tab(WindowId(3), true), Some(WindowId(1)));
assert!(tree.leave_tab_group(WindowId(1)).unwrap());
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
assert_eq!(tree.tab_group(WindowId(4)).unwrap().windows, [WindowId(3), WindowId(4)]);
assert_eq!(
tree.tab_group(WindowId(4)).unwrap().windows,
[WindowId(3), WindowId(4)]
);
assert!(tree.tab_group(WindowId(1)).is_none());
assert!(!tree.leave_tab_group(WindowId(1)).unwrap());
tree.remove(WindowId(4)).unwrap();
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -88,7 +88,8 @@ impl Adaptive {
}
pub fn deadline(&self) -> Option<Duration> {
self.pending.map(|(_, since)| since.saturating_add(HYSTERESIS))
self.pending
.map(|(_, since)| since.saturating_add(HYSTERESIS))
}
}
+38 -16
View File
@@ -180,7 +180,9 @@ impl Hub {
pins.iter()
.any(|value| !value.is_finite() || !(0. ..=f64::from(f32::MAX)).contains(value))
}) {
return Err("hub radius pins must be finite, nonnegative pixel values representable as f32");
return Err(
"hub radius pins must be finite, nonnegative pixel values representable as f32",
);
}
self.pins = pins;
let mut target = self.motion.target();
@@ -221,7 +223,10 @@ impl Hub {
}
pub fn retarget(&mut self, mode: Mode, available: [f64; 2]) -> Result<(), &'static str> {
if available.iter().any(|value| !value.is_finite() || *value <= 0.) {
if available
.iter()
.any(|value| !value.is_finite() || *value <= 0.)
{
return Err("hub requires a finite positive available area");
}
let [width, height] = self.profile.size(mode, self.height);
@@ -302,12 +307,14 @@ impl Hub {
pub fn sample(&self) -> Sample {
let fade = |elapsed: Duration| (elapsed.as_secs_f32() / FADE.as_secs_f32()).clamp(0., 1.);
let opacity =
self.initial_opacity + (1. - self.initial_opacity) * fade(self.elapsed.saturating_sub(CONTENT_DELAY));
let opacity = self.initial_opacity
+ (1. - self.initial_opacity) * fade(self.elapsed.saturating_sub(CONTENT_DELAY));
let mut geometry = self.motion.current();
geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]);
geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]);
geometry.radii = geometry.radii.map(|radius| radius.clamp(0., f64::from(f32::MAX)));
geometry.radii = geometry
.radii
.map(|radius| radius.clamp(0., f64::from(f32::MAX)));
Sample {
geometry,
previous: self.previous,
@@ -352,7 +359,8 @@ mod tests {
#[test]
fn measured_height_preserves_fade_and_motion_and_caps_long_content() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.])
.unwrap();
hub.advance(Duration::from_millis(180), false);
let before = hub.sample();
hub.fit_content_height(148.).unwrap();
@@ -363,7 +371,10 @@ mod tests {
assert_eq!(hub.sample().geometry.bounds.height, 148.);
assert_eq!(hub.sample().opacity, 1.);
hub.fit_content_height(900.).unwrap();
assert_eq!(hub.target_size(), [340., Profile::default().controls_height]);
assert_eq!(
hub.target_size(),
[340., Profile::default().controls_height]
);
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
hub.fit_content_height(100.).unwrap();
assert_eq!(hub.target_size(), [150., 34.]);
@@ -372,7 +383,8 @@ mod tests {
#[test]
fn a_content_swap_fades_without_restarting_the_geometry() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.])
.unwrap();
hub.advance(Duration::from_secs(2), false);
hub.advance(Duration::from_secs(1), false);
let geometry = hub.sample().geometry;
@@ -394,7 +406,8 @@ mod tests {
fn content_swap_uses_a_fixed_delay_instead_of_waiting_for_spring_settlement() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
assert!(!hub.active());
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap();
assert_eq!(hub.target_size(), [340., 230.]);
hub.advance(Duration::from_millis(120), false);
assert_eq!(hub.sample().opacity, 0.);
@@ -403,7 +416,10 @@ mod tests {
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
hub.advance(Duration::from_millis(60), false);
assert_eq!(hub.sample().opacity, 1.);
assert!(hub.active(), "content finishes while the shape is still settling");
assert!(
hub.active(),
"content finishes while the shape is still settling"
);
for _ in 0..50 {
hub.advance(Duration::from_millis(16), false);
}
@@ -415,9 +431,11 @@ mod tests {
#[test]
fn an_interrupted_content_delay_never_reveals_the_cancelled_panel() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.])
.unwrap();
hub.advance(Duration::from_millis(80), false);
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap();
for _ in 0..130 {
hub.advance(Duration::from_millis(8), false);
let sample = hub.sample();
@@ -431,7 +449,8 @@ mod tests {
#[test]
fn spring_overshoots_then_settles_within_its_reserved_surface() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap();
let allocation = hub.allocation_size();
let mut peak = 340_f64;
for _ in 0..200 {
@@ -450,7 +469,8 @@ mod tests {
#[test]
fn overshoot_is_clipped_to_small_outputs_and_reduced_motion_removes_all_fades() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.])
.unwrap();
for _ in 0..60 {
hub.advance(Duration::from_millis(8), false);
let bounds = hub.sample().geometry.bounds;
@@ -467,7 +487,8 @@ mod tests {
#[test]
fn interrupted_expansion_keeps_the_presented_shape() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.])
.unwrap();
hub.advance(Duration::from_millis(40), false);
let before = hub.sample().geometry;
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
@@ -482,7 +503,8 @@ mod tests {
#[test]
fn narrow_outputs_and_reduced_motion_keep_the_hub_bounded() {
let mut hub = Hub::new(Profile::default(), 30.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.])
.unwrap();
hub.advance(Duration::ZERO, true);
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 240., 180.));
assert_eq!(hub.sample().opacity, 1.);
+23 -7
View File
@@ -31,7 +31,12 @@ impl Geometry {
fn values(self) -> [f64; COUNT] {
let mut values = [0.; COUNT];
values[..4].copy_from_slice(&[self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height]);
values[..4].copy_from_slice(&[
self.bounds.x,
self.bounds.y,
self.bounds.width,
self.bounds.height,
]);
values[4..8].copy_from_slice(&self.radii);
values[8..12].copy_from_slice(&self.margin);
values[12..16].copy_from_slice(&self.thickness);
@@ -53,7 +58,9 @@ impl Geometry {
fn validate(self) -> Result<Self, &'static str> {
let values = self.values();
if values.iter().any(|value| !value.is_finite()) || values[2..].iter().any(|value| *value < 0.) {
if values.iter().any(|value| !value.is_finite())
|| values[2..].iter().any(|value| *value < 0.)
{
Err("panel animation geometry must be finite with nonnegative extents")
} else {
Ok(self)
@@ -82,7 +89,10 @@ impl Motion {
)
}
pub(crate) fn with_spring(geometry: Geometry, spring: SpringConfig) -> Result<Self, &'static str> {
pub(crate) fn with_spring(
geometry: Geometry,
spring: SpringConfig,
) -> Result<Self, &'static str> {
Ok(Self {
values: geometry.validate()?.values().map(AnimatedValue::new),
spring,
@@ -106,14 +116,16 @@ impl Motion {
// Extrema alternate; later peaks decay, so the first two suffice.
for phase in [first, first + std::f64::consts::PI] {
let t = phase / frequency;
let position =
value.target + (-decay * t).exp() * (displacement * phase.cos() + sine * phase.sin());
let position = value.target
+ (-decay * t).exp() * (displacement * phase.cos() + sine * phase.sin());
maximum = maximum.max(position);
}
} else {
// Total spring energy bounds displacement for any nonnegative damping.
maximum = maximum.max(
value.target + (displacement * displacement + value.velocity * value.velocity / omega2).sqrt(),
value.target
+ (displacement * displacement + value.velocity * value.velocity / omega2)
.sqrt(),
);
}
maximum
@@ -229,7 +241,11 @@ mod tests {
let before = motion.current();
let velocity = motion.values.map(|value| value.velocity);
motion
.retarget(if turn % 2 == 0 { expanded() } else { collapsed() })
.retarget(if turn % 2 == 0 {
expanded()
} else {
collapsed()
})
.unwrap();
assert_eq!(motion.current(), before);
assert_eq!(motion.values.map(|value| value.velocity), velocity);
+70 -16
View File
@@ -4,10 +4,14 @@ use std::f64::consts::{FRAC_PI_2, PI};
pub type Contour = Vec<[f64; 2]>;
fn arc(points: &mut Contour, center: [f64; 2], radius: f64, start: f64, tolerance: f64) {
let segments = ((FRAC_PI_2 / (8. * tolerance / radius.max(tolerance)).sqrt()).ceil() as usize).clamp(1, 128);
let segments = ((FRAC_PI_2 / (8. * tolerance / radius.max(tolerance)).sqrt()).ceil() as usize)
.clamp(1, 128);
for i in 0..=segments {
let angle = start + FRAC_PI_2 * i as f64 / segments as f64;
points.push([center[0] + radius * angle.cos(), center[1] + radius * angle.sin()]);
points.push([
center[0] + radius * angle.cos(),
center[1] + radius * angle.sin(),
]);
}
}
@@ -66,20 +70,32 @@ pub fn contours(scene: &Scene, tolerance: f64) -> Result<Vec<Contour>, &'static
.collect();
if scene.fusion > 0. {
for zone in 1..=3 {
let shapes: Vec<_> = scene.paint.iter().filter(|p| p.blend_zone == zone).copied().collect();
let shapes: Vec<_> = scene
.paint
.iter()
.filter(|p| p.blend_zone == zone)
.copied()
.collect();
if shapes.is_empty() {
continue;
}
// Fused rows share their vertical baseline. Each component is
// vertically convex; solve its upper and lower boundary, rather
// than sampling every pixel of a full-output bitmap.
let top = shapes.iter().map(|p| p.bounds.y).fold(f64::INFINITY, f64::min);
let top = shapes
.iter()
.map(|p| p.bounds.y)
.fold(f64::INFINITY, f64::min);
let bottom = shapes
.iter()
.map(|p| p.bounds.y + p.bounds.height)
.fold(f64::NEG_INFINITY, f64::max);
let mid = (top + bottom) * 0.5;
let left = shapes.iter().map(|p| p.bounds.x).fold(f64::INFINITY, f64::min) - scene.fusion;
let left = shapes
.iter()
.map(|p| p.bounds.x)
.fold(f64::INFINITY, f64::min)
- scene.fusion;
let right = shapes
.iter()
.map(|p| p.bounds.x + p.bounds.width)
@@ -135,7 +151,11 @@ pub fn contours(scene: &Scene, tolerance: f64) -> Result<Vec<Contour>, &'static
/// Open border paths for screen-attached notches. Split shared straight edges
/// before cancelling them, so the body and its fillets have one continuous rim.
pub fn notch_border(scene: &Scene, tolerance: f64, edge: crate::Edge) -> Result<Vec<Contour>, &'static str> {
pub fn notch_border(
scene: &Scene,
tolerance: f64,
edge: crate::Edge,
) -> Result<Vec<Contour>, &'static str> {
let contours = contours(scene, tolerance)?;
let vertices: Vec<_> = contours.iter().flatten().copied().collect();
let screen_edge = vertices.iter().map(|p| p[1]).fold(
@@ -144,13 +164,23 @@ pub fn notch_border(scene: &Scene, tolerance: f64, edge: crate::Edge) -> Result<
} else {
f64::NEG_INFINITY
},
if edge == crate::Edge::Top { f64::min } else { f64::max },
if edge == crate::Edge::Top {
f64::min
} else {
f64::max
},
);
let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let mut segments: Vec<([f64; 2], [f64; 2])> = Vec::new();
for contour in &contours {
for (&a, &b) in contour.iter().zip(contour.iter().cycle().skip(1)).take(contour.len()) {
if close(a, b) || ((a[1] - screen_edge).abs() < 1e-6 && (b[1] - screen_edge).abs() < 1e-6) {
for (&a, &b) in contour
.iter()
.zip(contour.iter().cycle().skip(1))
.take(contour.len())
{
if close(a, b)
|| ((a[1] - screen_edge).abs() < 1e-6 && (b[1] - screen_edge).abs() < 1e-6)
{
continue;
}
let delta = [b[0] - a[0], b[1] - a[1]];
@@ -181,7 +211,10 @@ pub fn notch_border(scene: &Scene, tolerance: f64, edge: crate::Edge) -> Result<
let mut paths = Vec::new();
while let Some((a, b)) = segments.pop() {
let mut path = vec![a, b];
while let Some(index) = segments.iter().position(|&(a, _)| close(a, *path.last().unwrap())) {
while let Some(index) = segments
.iter()
.position(|&(a, _)| close(a, *path.last().unwrap()))
{
path.push(segments.swap_remove(index).1);
}
while let Some(index) = segments.iter().position(|&(_, b)| close(b, path[0])) {
@@ -202,7 +235,11 @@ mod tests {
fn winding(contours: &[Contour], point: [f64; 2]) -> i32 {
let mut winding = 0;
for points in contours {
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
for (a, b) in points
.iter()
.zip(points.iter().cycle().skip(1))
.take(points.len())
{
let cross = (b[0] - a[0]) * (point[1] - a[1]) - (point[0] - a[0]) * (b[1] - a[1]);
if a[1] <= point[1] && b[1] > point[1] && cross > 0. {
winding += 1;
@@ -241,14 +278,22 @@ mod tests {
let screen_y = if edge == crate::Edge::Top { 0. } else { 44. };
assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6);
assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6);
let body = scene.paint.iter().find(|p| p.operation == 0).unwrap().bounds;
let body = scene
.paint
.iter()
.find(|p| p.operation == 0)
.unwrap()
.bounds;
assert!(path.iter().any(|p| p[0] < body.x));
assert!(path.iter().any(|p| p[0] > body.x + body.width));
for segment in path.windows(2) {
let [a, b] = [segment[0], segment[1]];
assert!((a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6);
let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.];
assert!(scene.distance(mid).abs() < 0.12 / scale, "off outline: {mid:?}");
assert!(
scene.distance(mid).abs() < 0.12 / scale,
"off outline: {mid:?}"
);
// A shared body/fillet seam has fill on both sides, unlike a visible rim.
let delta = [b[0] - a[0], b[1] - a[1]];
let length = delta[0].hypot(delta[1]);
@@ -266,7 +311,9 @@ mod tests {
fn attached_morph_rim_follows_animated_body_at_fractional_scales() {
for scale in [1., 1.25, 1.5, 2.] {
for edge in [crate::Edge::Top, crate::Edge::Bottom] {
for (width, height, radius) in [(150., 34., 17.), (218.3, 41.7, 20.85), (340., 230., 26.)] {
for (width, height, radius) in
[(150., 34., 17.), (218.3, 41.7, 20.85), (340., 230., 26.)]
{
let scene = derive(
Parameters {
style: Style::Morph,
@@ -283,7 +330,11 @@ mod tests {
.unwrap();
let body = scene.paint[0];
assert_eq!(scene.paint.len(), 3);
assert_eq!(scene.input.len(), 1, "decorative shoulders must not take pointer input");
assert_eq!(
scene.input.len(),
1,
"decorative shoulders must not take pointer input"
);
let screen_y = if edge == crate::Edge::Top {
body.bounds.y
} else {
@@ -299,7 +350,10 @@ mod tests {
assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6);
assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6);
assert!(path.iter().any(|p| p[0] < body.bounds.x));
assert!(path.iter().any(|p| p[0] > body.bounds.x + body.bounds.width));
assert!(
path.iter()
.any(|p| p[0] > body.bounds.x + body.bounds.width)
);
for segment in path.windows(2) {
let [a, b] = [segment[0], segment[1]];
assert!(
+48 -12
View File
@@ -220,9 +220,12 @@ impl<'de> Deserialize<'de> for Item {
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")
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"));
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")
@@ -447,7 +450,10 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> {
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()));
return Err(format!(
"panel style {} is not available yet",
panel.style.key()
));
}
panel.geometry.validate()?;
panel.morph.validate()?;
@@ -497,7 +503,10 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> {
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());
return Err(
"overflow is supplied by the panel; it cannot be configured as an item"
.into(),
);
}
}
}
@@ -538,7 +547,11 @@ impl Panel {
}
pub fn zone_spacing(&self, zone: &Zone) -> f32 {
if self.style.connected() { 0. } else { zone.spacing }
if self.style.connected() {
0.
} else {
zone.spacing
}
}
pub fn shape_parameters(&self) -> crate::scene::Parameters {
@@ -606,7 +619,8 @@ impl Panel {
}
pub fn resolved_radius(&self, shell_radius: f32) -> CornerRadii {
self.corner_radius.unwrap_or_else(|| CornerRadii::uniform(shell_radius))
self.corner_radius
.unwrap_or_else(|| CornerRadii::uniform(shell_radius))
}
/// The overflow trigger is generated, never persisted as a configured item.
@@ -629,7 +643,9 @@ impl Panel {
.end
.groups
.first()
.map_or(crate::default_island_padding(), |group| group.island_padding),
.map_or(crate::default_island_padding(), |group| {
group.island_padding
}),
}
}
@@ -685,7 +701,12 @@ impl Panel {
let mut controls = controls;
controls.extend([clock, display]);
(
vec![Group::new("navigation", vec![overview, workspaces], [2, 3], padding)],
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)],
)
@@ -703,7 +724,11 @@ impl Panel {
corner_radius: Option::None,
start: Zone {
groups: start,
spacing: if preset == PanelStyle::Floating { 8.0 } else { 3.0 },
spacing: if preset == PanelStyle::Floating {
8.0
} else {
3.0
},
},
center: Zone {
groups: vec![center],
@@ -821,7 +846,9 @@ mod tests {
battery: true,
external_display: true
}),
all.iter().map(|item| item.id.0.as_str()).collect::<Vec<_>>()
all.iter()
.map(|item| item.id.0.as_str())
.collect::<Vec<_>>()
);
assert_eq!(items(&panel).len(), 13);
}
@@ -842,7 +869,10 @@ mod tests {
{
group.island_padding = 13.;
}
assert_eq!(panel.center.groups[0].items[0].kind, ItemKind::FocusedWindow);
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!(
@@ -854,7 +884,13 @@ mod tests {
assert_eq!(group.island_padding, 13.0);
}
}
assert_eq!(all.iter().find(|item| item.id.0 == "clock").unwrap().gap_before, None);
assert_eq!(
all.iter()
.find(|item| item.id.0 == "clock")
.unwrap()
.gap_before,
None
);
}
#[test]
+21 -4
View File
@@ -25,8 +25,16 @@ impl GeometryIntent {
Morph => 34.,
_ => 36.,
}),
edge_margin: if flush { 0 } else { self.edge_margin.unwrap_or(8) },
side_margins: if flush { 0 } else { self.side_margins.unwrap_or(8) },
edge_margin: if flush {
0
} else {
self.edge_margin.unwrap_or(8)
},
side_margins: if flush {
0
} else {
self.side_margins.unwrap_or(8)
},
window_clearance: self.window_clearance.unwrap_or(0),
inner_padding: self.inner_padding.unwrap_or(12.),
}
@@ -74,7 +82,10 @@ impl PanelGeometry {
.into_iter()
.any(|value| !(0..=4096).contains(&value))
{
return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into());
return Err(
"panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096"
.into(),
);
}
Ok(())
}
@@ -101,7 +112,13 @@ mod tests {
assert_eq!(pins.resolve(Flat).side_margins, 0);
assert_eq!(pins.resolve(Floating).side_margins, 18);
assert_eq!(pins.resolve(Notch).edge_margin, 0);
assert!(serde_json::to_value(intent).unwrap().as_object().unwrap().is_empty());
assert!(
serde_json::to_value(intent)
.unwrap()
.as_object()
.unwrap()
.is_empty()
);
assert!(
GeometryIntent {
edge_margin: Some(-1),
+117 -31
View File
@@ -10,7 +10,10 @@ pub struct Measurement {
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
Visible { representation: Representation, width: f32 },
Visible {
representation: Representation,
width: f32,
},
Overflow,
}
@@ -103,7 +106,12 @@ impl Resolution {
}
}
pub fn resolve(panel: &Panel, measurements: &[Measurement], available: f32, overflow_width: f32) -> Resolution {
pub fn resolve(
panel: &Panel,
measurements: &[Measurement],
available: f32,
overflow_width: f32,
) -> Resolution {
resolve_with_gap(panel, measurements, available, overflow_width, 8.)
}
@@ -127,7 +135,10 @@ pub fn resolve_with_gap(
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.collect();
let measurements: BTreeMap<_, _> = measurements.iter().map(|measured| (&measured.id, measured)).collect();
let measurements: BTreeMap<_, _> = measurements
.iter()
.map(|measured| (&measured.id, measured))
.collect();
// Allocation policy follows composition, never the renderer's sample order.
// Lower priorities yield first; definition order breaks equal-priority ties.
let mut candidates: Vec<_> = ordered.iter().enumerate().collect();
@@ -143,9 +154,13 @@ pub fn resolve_with_gap(
result.items.insert(item.id.clone(), Placement::Overflow);
result.overflow.push(item.id.clone());
} else {
result
.items
.insert(item.id.clone(), Placement::Visible { representation, width });
result.items.insert(
item.id.clone(),
Placement::Visible {
representation,
width,
},
);
}
}
result.update_widths(panel, overflow_width);
@@ -164,13 +179,20 @@ pub fn resolve_with_gap(
result.minimum_width = minimum.required_width();
// Flexible content yields space without changing the stored item preference.
for (_, item) in &candidates {
let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else {
let Some(measured) = measurements
.get(&item.id)
.filter(|measured| measured.minimum.is_some())
else {
continue;
};
if fits(&result) {
break;
}
let Some(Placement::Visible { representation, width }) = result.items.get(&measured.id).cloned() else {
let Some(Placement::Visible {
representation,
width,
}) = result.items.get(&measured.id).cloned()
else {
continue;
};
let minimum = measured.minimum.unwrap().min(width);
@@ -191,12 +213,18 @@ pub fn resolve_with_gap(
continue;
};
for &(representation, width) in measured.alternatives.iter().skip(1) {
if let Some(Placement::Visible { width: previous, .. }) = result.items.get(&item.id)
if let Some(Placement::Visible {
width: previous, ..
}) = result.items.get(&item.id)
&& width < *previous
{
result
.items
.insert(item.id.clone(), Placement::Visible { representation, width });
result.items.insert(
item.id.clone(),
Placement::Visible {
representation,
width,
},
);
result.update_widths(panel, overflow_width);
if fits(&result) {
break;
@@ -214,7 +242,8 @@ pub fn resolve_with_gap(
if item.overflow == OverflowPolicy::Never && !force {
continue;
}
if matches!(result.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0) {
if matches!(result.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0)
{
result.items.insert(item.id.clone(), Placement::Overflow);
result.overflow.push(item.id.clone());
result.forced_overflow |= force && item.overflow == OverflowPolicy::Never;
@@ -235,7 +264,11 @@ pub fn resolve_with_gap(
let Some(measured) = measurements.get(&item.id) else {
continue;
};
let Some(&(representation, width)) = measured.alternatives.iter().min_by(|a, b| a.1.total_cmp(&b.1)) else {
let Some(&(representation, width)) = measured
.alternatives
.iter()
.min_by(|a, b| a.1.total_cmp(&b.1))
else {
continue;
};
result.items.insert(
@@ -255,7 +288,8 @@ pub fn resolve_with_gap(
}
}
result.forced_overflow = ordered.iter().any(|item| {
item.overflow == OverflowPolicy::Never && matches!(result.items.get(&item.id), Some(Placement::Overflow))
item.overflow == OverflowPolicy::Never
&& matches!(result.items.get(&item.id), Some(Placement::Overflow))
});
// A reduction on the shorter side can leave unused room after the other
// side shrinks. Restore preferred representations before expanding titles.
@@ -263,7 +297,9 @@ pub fn resolve_with_gap(
let Some(measured) = measurements.get(&item.id) else {
continue;
};
let Some(previous @ Placement::Visible { representation, .. }) = result.items.get(&item.id).cloned() else {
let Some(previous @ Placement::Visible { representation, .. }) =
result.items.get(&item.id).cloned()
else {
continue;
};
for &(candidate, width) in measured
@@ -288,7 +324,10 @@ pub fn resolve_with_gap(
result.update_widths(panel, overflow_width);
// Restore the most protected flexible items first, reversing the yield order.
for (_, item) in candidates.iter().rev() {
let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else {
let Some(measured) = measurements
.get(&item.id)
.filter(|measured| measured.minimum.is_some())
else {
continue;
};
let Some(Placement::Visible {
@@ -309,9 +348,16 @@ pub fn resolve_with_gap(
let mut high = maximum;
for _ in 0..20 {
let width = (low + high) * 0.5;
*result.items.get_mut(&measured.id).unwrap() = Placement::Visible { representation, width };
*result.items.get_mut(&measured.id).unwrap() = Placement::Visible {
representation,
width,
};
result.update_widths(panel, overflow_width);
if fits(&result) { low = width } else { high = width }
if fits(&result) {
low = width
} else {
high = width
}
}
*result.items.get_mut(&measured.id).unwrap() = Placement::Visible {
representation,
@@ -333,7 +379,11 @@ mod tests {
fn asymmetric_overflow_restores_items_that_fit_after_the_larger_side_yields() {
use super::super::{Group, Item, ItemKind};
for mirrored in [false, true] {
for policy in [OverflowPolicy::Auto, OverflowPolicy::Never, OverflowPolicy::Always] {
for policy in [
OverflowPolicy::Auto,
OverflowPolicy::Never,
OverflowPolicy::Always,
] {
let group = |id: &str, priority, overflow| {
let mut item = Item::new(id, ItemKind::Clock);
item.priority = priority;
@@ -360,7 +410,10 @@ mod tests {
let resolution = resolve(&panel, &measurements, 300., 32.);
assert!(resolution.fits(300.));
assert_eq!(
matches!(resolution.items[&ItemId("small".into())], Placement::Visible { .. }),
matches!(
resolution.items[&ItemId("small".into())],
Placement::Visible { .. }
),
policy != OverflowPolicy::Always
);
assert!(resolution.overflow.contains(&ItemId("large".into())));
@@ -381,10 +434,18 @@ mod tests {
let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.);
panel.style = crate::PanelStyle::Floating;
group.island_padding = 4.;
let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.);
let empty = Group::new(
"empty",
vec![Item::new("missing", ItemKind::Media)],
[0, 99],
99.,
);
let trailing = Group::new(
"trailing",
vec![Item::new("trailing", ItemKind::Battery { percentage: false })],
vec![Item::new(
"trailing",
ItemKind::Battery { percentage: false },
)],
[0, 2],
5.,
);
@@ -630,9 +691,15 @@ mod tests {
let expected = resolve_with_gap(&panel, &measured, width, 32., 16.);
for _ in 0..measured.len() {
measured.rotate_left(1);
assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected);
assert_eq!(
resolve_with_gap(&panel, &measured, width, 32., 16.),
expected
);
measured.reverse();
assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected);
assert_eq!(
resolve_with_gap(&panel, &measured, width, 32., 16.),
expected
);
measured.reverse();
}
}
@@ -700,7 +767,10 @@ mod tests {
let margin = full.side_margin(requested, output_width, 12.);
let available = output_width as f32 - 2. * (margin as f32 + 12.);
let result = resolve(&panel, &measurements, available, 32.);
assert!(result.overflow.is_empty(), "output {output_width}, margin {margin}");
assert!(
result.overflow.is_empty(),
"output {output_width}, margin {margin}"
);
assert!(result.fits(available));
assert!(margin <= requested);
}
@@ -714,7 +784,8 @@ mod tests {
}
#[test]
fn margin_limit_includes_group_padding_and_authored_overflow_without_dependence_on_current_surface_width() {
fn margin_limit_includes_group_padding_and_authored_overflow_without_dependence_on_current_surface_width()
{
let mut panel = Panel::default();
panel.end.groups[0].items[0].overflow = OverflowPolicy::Always;
let measurements = measurements(&panel);
@@ -722,7 +793,12 @@ mod tests {
let narrow = resolve(&panel, &measurements, 100., 32.);
assert_eq!(full.minimum_width, narrow.minimum_width);
let margin = full.side_margin(4096, 2400, 12.);
let result = resolve(&panel, &measurements, 2400. - 2. * (margin as f32 + 12.), 32.);
let result = resolve(
&panel,
&measurements,
2400. - 2. * (margin as f32 + 12.),
32.,
);
assert_eq!(result.overflow, full.overflow);
let original = full.minimum_width;
panel.end.groups[0].padding[1] += 10;
@@ -794,7 +870,10 @@ mod tests {
assert!(result.overflow.is_empty());
assert!(result.items.values().all(|placement| matches!(placement, Placement::Visible { representation: Representation::Wide, width } if *width == 50.)));
}
assert_eq!(panel, initial, "resolution must not rewrite configuration intent");
assert_eq!(
panel, initial,
"resolution must not rewrite configuration intent"
);
}
}
#[test]
@@ -804,8 +883,15 @@ mod tests {
let initial = resolve(&panel, &measured, 2400., 32.);
panel.style = crate::PanelStyle::Flat;
let plain = resolve(&panel, &measured, 2400., 32.);
for (i, zone) in [&panel.start, &panel.center, &panel.end].into_iter().enumerate() {
let padding: f32 = zone.groups.iter().map(|group| 2. * group.island_padding).sum();
for (i, zone) in [&panel.start, &panel.center, &panel.end]
.into_iter()
.enumerate()
{
let padding: f32 = zone
.groups
.iter()
.map(|group| 2. * group.island_padding)
.sum();
assert_eq!(plain.zone_widths[i], initial.zone_widths[i] - padding);
}
panel.style = crate::PanelStyle::Flat;
+10 -3
View File
@@ -79,7 +79,8 @@ impl std::str::FromStr for CornerRadii {
let number = if part == "0" {
part
} else {
part.strip_suffix("px").ok_or("use pixel values such as 4px")?
part.strip_suffix("px")
.ok_or("use pixel values such as 4px")?
};
number.parse::<f32>().map_err(|_| "invalid corner radius")
})
@@ -152,7 +153,10 @@ mod tests {
let radii = CornerRadii([4., 8., 12., 16.]);
assert_eq!(radii.at_top_edge(true).0, [0., 0., 12., 16.]);
assert_eq!(radii.at_top_edge(false), radii);
assert_eq!(radii.at_edge(super::super::Edge::Bottom, true).0, [4., 8., 0., 0.]);
assert_eq!(
radii.at_edge(super::super::Edge::Bottom, true).0,
[4., 8., 0., 0.]
);
assert_eq!(radii.at_edge(super::super::Edge::Bottom, false), radii);
assert_eq!(radii.0, [4., 8., 12., 16.]);
}
@@ -171,6 +175,9 @@ mod tests {
] {
assert!(source.parse::<CornerRadii>().is_err(), "{source}");
}
assert_eq!(CornerRadii([4., 40., 0., 18.]).clamped(100., 28.).0, [4., 14., 0., 14.]);
assert_eq!(
CornerRadii([4., 40., 0., 18.]).clamped(100., 28.).0,
[4., 14., 0., 14.]
);
}
}
+81 -21
View File
@@ -17,7 +17,12 @@ pub struct Primitive {
impl Primitive {
pub fn packet(self) -> [f32; 10] {
let Rect { x, y, width, height } = self.bounds;
let Rect {
x,
y,
width,
height,
} = self.bounds;
let [tl, tr, br, bl] = self.radii.0;
[
x as f32,
@@ -34,7 +39,9 @@ impl Primitive {
}
pub fn from_packet(values: [f32; 10]) -> Result<Self, &'static str> {
if !values.into_iter().all(|v| v.is_finite() && v.abs() <= 32768.)
if !values
.into_iter()
.all(|v| v.is_finite() && v.abs() <= 32768.)
|| values[2] <= 0.
|| values[3] <= 0.
|| values[4..8].iter().any(|v| *v < 0.)
@@ -46,8 +53,14 @@ impl Primitive {
return Err("invalid panel shape primitive");
}
Ok(Self {
bounds: Rect::new(values[0].into(), values[1].into(), values[2].into(), values[3].into()),
radii: CornerRadii([values[4], values[5], values[6], values[7]]).normalized(values[2], values[3]),
bounds: Rect::new(
values[0].into(),
values[1].into(),
values[2].into(),
values[3].into(),
),
radii: CornerRadii([values[4], values[5], values[6], values[7]])
.normalized(values[2], values[3]),
operation: values[8] as u8,
blend_zone: values[9] as u8,
})
@@ -63,7 +76,8 @@ impl Primitive {
4 => [b.x + b.width, b.y],
_ => [b.x, b.y],
};
return rect.max(b.width.min(b.height) - (point[0] - center[0]).hypot(point[1] - center[1]));
return rect
.max(b.width.min(b.height) - (point[0] - center[0]).hypot(point[1] - center[1]));
}
let b = self.bounds;
let (x, y) = (point[0] - b.x, point[1] - b.y);
@@ -150,12 +164,21 @@ impl Scene {
let bottom = ((b.y + b.height) * scale).ceil() as i32;
for y in top..bottom {
let mut first = left;
while first < right && !shape.contains([(f64::from(first) + 0.5) / scale, (f64::from(y) + 0.5) / scale])
while first < right
&& !shape.contains([
(f64::from(first) + 0.5) / scale,
(f64::from(y) + 0.5) / scale,
])
{
first += 1;
}
let mut last = right;
while last > first && !shape.contains([(f64::from(last) - 0.5) / scale, (f64::from(y) + 0.5) / scale]) {
while last > first
&& !shape.contains([
(f64::from(last) - 0.5) / scale,
(f64::from(y) + 0.5) / scale,
])
{
last -= 1;
}
if first == last {
@@ -328,8 +351,18 @@ pub fn derive(
Style::CornerWrap => {
let h = parameters.height.min(bounds.height);
let w = parameters.wrap_width.min(bounds.width);
let horizontal = Rect::new(bounds.x, bounds.y, parameters.wrap_size[0].min(bounds.width), h);
let vertical = Rect::new(bounds.x, bounds.y, w, parameters.wrap_size[1].min(bounds.height));
let horizontal = Rect::new(
bounds.x,
bounds.y,
parameters.wrap_size[0].min(bounds.width),
h,
);
let vertical = Rect::new(
bounds.x,
bounds.y,
w,
parameters.wrap_size[1].min(bounds.height),
);
add(
&mut scene,
grid,
@@ -338,7 +371,14 @@ pub fn derive(
true,
0,
)?;
add(&mut scene, grid, vertical, CornerRadii([0., 0., 14., 0.]), true, 0)?;
add(
&mut scene,
grid,
vertical,
CornerRadii([0., 0., 14., 0.]),
true,
0,
)?;
// Capture the union once: overlapping translucent captures darken the elbow.
scene.damage = vec![
horizontal,
@@ -388,8 +428,12 @@ pub fn derive(
// One material capture owns the body and both shoulders. Separate
// captures overlap after logical rounding at fractional scales,
// painting translucent joins twice as the spring settles.
scene.damage =
vec![grid.rect(Rect::new(bounds.x - r, bounds.y, bounds.width + 2. * r, bounds.height))?];
scene.damage = vec![grid.rect(Rect::new(
bounds.x - r,
bounds.y,
bounds.width + 2. * r,
bounds.height,
))?];
}
}
}
@@ -406,7 +450,10 @@ pub fn derive(
continue;
}
if style.connected() {
let x = occupied.iter().map(|g| g.bounds.x).fold(f64::INFINITY, f64::min);
let x = occupied
.iter()
.map(|g| g.bounds.x)
.fold(f64::INFINITY, f64::min);
let end = occupied
.iter()
.map(|g| g.bounds.x + g.bounds.width)
@@ -440,9 +487,12 @@ pub fn derive(
add(&mut scene, grid, body, radii, true, 0)?;
for group in occupied.iter().take(occupied.len() - 1) {
let seam = grid.snap(group.bounds.x + group.bounds.width);
scene
.dividers
.push([seam, bounds.y + 8., seam, bounds.y + bounds.height - 8.]);
scene.dividers.push([
seam,
bounds.y + 8.,
seam,
bounds.y + bounds.height - 8.,
]);
}
if style == Style::Notch && zone == ZonePosition::Center {
let r = parameters
@@ -469,7 +519,8 @@ pub fn derive(
}
} else {
for group in occupied {
let body = Rect::new(group.bounds.x, bounds.y, group.bounds.width, bounds.height);
let body =
Rect::new(group.bounds.x, bounds.y, group.bounds.width, bounds.height);
let radii = parameters
.radius
.map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| {
@@ -500,7 +551,8 @@ pub fn derive(
if let Some(first) = shapes.next() {
let mut region = first.bounds;
for shape in shapes {
let right = (region.x + region.width).max(shape.bounds.x + shape.bounds.width);
let right =
(region.x + region.width).max(shape.bounds.x + shape.bounds.width);
region.x = region.x.min(shape.bounds.x);
region.width = right - region.x;
}
@@ -510,7 +562,9 @@ pub fn derive(
}
}
}
if parameters.edge == Edge::Bottom && matches!(parameters.style, Style::Frame | Style::CornerWrap) {
if parameters.edge == Edge::Bottom
&& matches!(parameters.style, Style::Frame | Style::CornerWrap)
{
let mirror = |mut r: Rect| {
r.y = 2. * bounds.y + bounds.height - r.y - r.height;
r
@@ -529,7 +583,8 @@ pub fn derive(
}
for input in &mut scene.input {
let [tl, tr, br, bl] = input.radii().0;
*input = RoundedSurface::new(mirror(input.bounds()), CornerRadii([bl, br, tr, tl]), grid)?;
*input =
RoundedSurface::new(mirror(input.bounds()), CornerRadii([bl, br, tr, tl]), grid)?;
}
for damage in &mut scene.damage {
*damage = mirror(*damage);
@@ -629,7 +684,12 @@ mod tests {
assert!(!scene.contains([38., 38.])); // the decorative elbow is not input
for (i, a) in scene.damage.iter().enumerate() {
for b in &scene.damage[i + 1..] {
assert!(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
assert!(
a.x + a.width <= b.x
|| b.x + b.width <= a.x
|| a.y + a.height <= b.y
|| b.y + b.height <= a.y
);
}
}
for x in (0..800).step_by(3) {
+98 -17
View File
@@ -77,7 +77,12 @@ impl Style {
pub const fn detached(self) -> bool {
matches!(
self,
Self::Floating | Self::Capsules | Self::Fused | Self::Morph | Self::Adaptive | Self::WindowHug
Self::Floating
| Self::Capsules
| Self::Fused
| Self::Morph
| Self::Adaptive
| Self::WindowHug
)
}
}
@@ -129,7 +134,10 @@ pub fn effective_radii(
index: usize,
count: usize,
) -> Result<CornerRadii, &'static str> {
if !size.into_iter().all(|value| value.is_finite() && value >= 0.) {
if !size
.into_iter()
.all(|value| value.is_finite() && value >= 0.)
{
return Err("panel dimensions must be finite and nonnegative");
}
if index >= count {
@@ -137,9 +145,12 @@ pub fn effective_radii(
}
let [width, height] = size;
let base = AnchoredRadii(match style {
Style::Floating | Style::Capsules | Style::Fused | Style::Morph | Style::Adaptive | Style::WindowHug => {
[height / 2.; 4]
}
Style::Floating
| Style::Capsules
| Style::Fused
| Style::Morph
| Style::Adaptive
| Style::WindowHug => [height / 2.; 4],
Style::Notch => match zone {
ZonePosition::Start => [0., 0., 14., 0.],
ZonePosition::Center => [0., 0., 20., 20.],
@@ -189,7 +200,12 @@ impl PixelGrid {
if ![left, top, right, bottom].into_iter().all(f64::is_finite) {
return Err("surface bounds exceed the physical pixel grid");
}
Ok(Rect::new(left, top, (right - left).max(0.), (bottom - top).max(0.)))
Ok(Rect::new(
left,
top,
(right - left).max(0.),
(bottom - top).max(0.),
))
}
}
@@ -266,7 +282,17 @@ mod tests {
assert_eq!(radius(1, 2).0, [0., 18., 18., 0.]);
assert_eq!(radius(0, 1).0, [18.; 4]);
assert_eq!(radius(0, 2).0, [18., 0., 0., 18.]);
assert!(effective_radii(Style::Capsules, ZonePosition::Start, Edge::Top, [100., 36.], 0, 0).is_err());
assert!(
effective_radii(
Style::Capsules,
ZonePosition::Start,
Edge::Top,
[100., 36.],
0,
0
)
.is_err()
);
}
#[test]
@@ -288,14 +314,25 @@ mod tests {
#[test]
fn asymmetric_corners_preserve_notch_shape_and_scale_proportionally() {
let notch = effective_radii(Style::Notch, ZonePosition::Center, Edge::Top, [100., 28.], 0, 1).unwrap();
let notch = effective_radii(
Style::Notch,
ZonePosition::Center,
Edge::Top,
[100., 28.],
0,
1,
)
.unwrap();
assert_eq!(notch.0, [0., 0., 20., 20.]);
assert_eq!(notch.normalized(30., 28.).0, [0., 0., 15., 15.]);
assert_eq!(
CornerRadii([10., 20., 40., 30.]).normalized(35., 25.).0,
[25. / 6., 25. / 3., 50. / 3., 12.5]
);
assert_eq!(CornerRadii::uniform(f32::MAX).normalized(40., 28.).0, [14.; 4]);
assert_eq!(
CornerRadii::uniform(f32::MAX).normalized(40., 28.).0,
[14.; 4]
);
assert_eq!(notch.normalized(0., 28.).0, [0.; 4]);
}
@@ -317,9 +354,25 @@ mod tests {
#[test]
fn floating_gaps_and_round_corners_do_not_capture_input() {
let grid = PixelGrid::new(1.25).unwrap();
let left = RoundedSurface::new(Rect::new(8., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap();
let right = RoundedSurface::new(Rect::new(116., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap();
for point in [[0., 0.], [8., 8.], [112., 26.], [216., 26.], [f64::NAN, 26.]] {
let left = RoundedSurface::new(
Rect::new(8., 8., 100., 36.),
CornerRadii::uniform(18.),
grid,
)
.unwrap();
let right = RoundedSurface::new(
Rect::new(116., 8., 100., 36.),
CornerRadii::uniform(18.),
grid,
)
.unwrap();
for point in [
[0., 0.],
[8., 8.],
[112., 26.],
[216., 26.],
[f64::NAN, 26.],
] {
assert!(!left.contains(point) && !right.contains(point), "{point:?}");
}
assert!(left.contains([30., 26.]));
@@ -330,7 +383,12 @@ mod tests {
fn asymmetric_hit_testing_matches_a_vertically_mirrored_surface() {
let grid = PixelGrid::new(1.).unwrap();
let bounds = Rect::new(0., 0., 100., 28.);
let top = RoundedSurface::new(bounds, AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Top), grid).unwrap();
let top = RoundedSurface::new(
bounds,
AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Top),
grid,
)
.unwrap();
let bottom = RoundedSurface::new(
bounds,
AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Bottom),
@@ -352,8 +410,18 @@ mod tests {
#[test]
fn a_connected_seam_has_exactly_one_input_owner() {
let grid = PixelGrid::new(1.5).unwrap();
let left = RoundedSurface::new(Rect::new(0., 0., 30., 36.), CornerRadii([18., 0., 0., 18.]), grid).unwrap();
let right = RoundedSurface::new(Rect::new(30., 0., 40., 36.), CornerRadii([0., 18., 18., 0.]), grid).unwrap();
let left = RoundedSurface::new(
Rect::new(0., 0., 30., 36.),
CornerRadii([18., 0., 0., 18.]),
grid,
)
.unwrap();
let right = RoundedSurface::new(
Rect::new(30., 0., 40., 36.),
CornerRadii([0., 18., 18., 0.]),
grid,
)
.unwrap();
for y in [0., 18., 35.9] {
assert!(!left.contains([30., y]));
assert!(right.contains([30., y]));
@@ -373,7 +441,20 @@ mod tests {
] {
assert!(grid.rect(bounds).is_err());
}
assert!(RoundedSurface::new(Rect::new(0., 0., 10., 28.), CornerRadii::uniform(-1.), grid).is_err());
assert!(effective_radii(Style::Floating, ZonePosition::Start, Edge::Top, [100., f32::NAN], 0, 1).is_err());
assert!(
RoundedSurface::new(Rect::new(0., 0., 10., 28.), CornerRadii::uniform(-1.), grid)
.is_err()
);
assert!(
effective_radii(
Style::Floating,
ZonePosition::Start,
Edge::Top,
[100., f32::NAN],
0,
1
)
.is_err()
);
}
}
+12 -4
View File
@@ -196,11 +196,15 @@ pub mod window_capture {
use wayland_client::protocol::__interfaces::*;
use wayland_protocols_wlr::screencopy::v1::client::__interfaces::*;
wayland_scanner::generate_interfaces!("../../protocols/ferese-window-capture-v1.xml");
wayland_scanner::generate_interfaces!(
"../../protocols/ferese-window-capture-v1.xml"
);
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("../../protocols/ferese-window-capture-v1.xml");
wayland_scanner::generate_client_code!(
"../../protocols/ferese-window-capture-v1.xml"
);
}
#[cfg(feature = "server")]
@@ -213,11 +217,15 @@ pub mod window_capture {
use wayland_protocols_wlr::screencopy::v1::server::__interfaces::*;
use wayland_server::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("../../protocols/ferese-window-capture-v1.xml");
wayland_scanner::generate_interfaces!(
"../../protocols/ferese-window-capture-v1.xml"
);
}
use self::__interfaces::*;
wayland_scanner::generate_server_code!("../../protocols/ferese-window-capture-v1.xml");
wayland_scanner::generate_server_code!(
"../../protocols/ferese-window-capture-v1.xml"
);
}
}
}
+12 -7
View File
@@ -33,8 +33,9 @@ impl ModalMaterial {
return Err("Dialog materials require Wayland".into());
};
// Iced lends live native handles; this backend borrows its connection.
let backend =
unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let connection = Connection::from_backend(backend);
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
@@ -43,7 +44,8 @@ impl ModalMaterial {
)
}
.map_err(|error| error.to_string())?;
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) =
registry_queue_init::<MaterialState>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
@@ -57,7 +59,9 @@ impl ModalMaterial {
material,
};
let mut state = MaterialState::default();
queue.roundtrip(&mut state).map_err(|error| error.to_string())?;
queue
.roundtrip(&mut state)
.map_err(|error| error.to_string())?;
if !state.ready {
return Err("Dialog material attachment was not acknowledged".into());
}
@@ -116,8 +120,8 @@ delegate_noop!(MaterialState: ignore FereseMaterialManagerV1);
mod tests {
use super::*;
use cosmic::iced::window::raw_window_handle::{
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
WindowHandle,
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WaylandDisplayHandle,
WaylandWindowHandle, WindowHandle,
};
use std::ptr::NonNull;
use wayland_client::protocol::wl_compositor;
@@ -166,7 +170,8 @@ mod tests {
queue.roundtrip(&mut MaterialState::default()).unwrap();
// The compositor rejects duplicate materials. Reattachment proves the final
// drop destroyed the first material while preserving the borrowed display.
let replacement = ModalMaterial::attach(&window).expect("last clone released the surface material");
let replacement =
ModalMaterial::attach(&window).expect("last clone released the surface material");
drop(replacement);
queue.roundtrip(&mut MaterialState::default()).unwrap();
window.surface.destroy();
+8 -4
View File
@@ -110,7 +110,9 @@ mod tests {
let server = std::thread::spawn(move || {
for revision in [1, 2] {
let (mut connection, _) = listener.accept().unwrap();
connection.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
connection
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let get: Request = read_frame(&mut connection).unwrap();
assert_eq!(get.command, "theme-get");
let mut snapshot = fallback();
@@ -145,9 +147,11 @@ mod tests {
.unwrap();
assert_eq!(snapshot.revision, revision);
}
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(5)).unwrap())
.await
.unwrap();
tokio::task::spawn_blocking(move || {
watch_receive.recv_timeout(Duration::from_secs(5)).unwrap()
})
.await
.unwrap();
drop(subscription);
});
server.join().unwrap();
+12 -5
View File
@@ -37,7 +37,8 @@ fn explicit_solid_style_round_trips_without_resolving_published_values() {
value[key]["tokens"]["focus_ring"]["style"] = json!("solid");
}
let snapshot = Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
let snapshot =
Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
assert_eq!(serde_json::to_value(snapshot).unwrap(), value);
}
@@ -46,7 +47,8 @@ fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
let mut value = fixture();
value.as_object_mut().unwrap().remove("families");
value.as_object_mut().unwrap().remove("fallback_note");
let snapshot: Snapshot = Snapshot::decode(value.clone(), ferese_config::families::builtins).unwrap();
let snapshot: Snapshot =
Snapshot::decode(value.clone(), ferese_config::families::builtins).unwrap();
assert_eq!(snapshot.families, ferese_config::families::builtins());
assert_eq!(snapshot.fallback_note, None);
value["families"] = json!([]);
@@ -63,7 +65,8 @@ fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
#[test]
fn offline_preview_resolves_without_changing_published_values() {
let before = fallback();
let document = ferese_config::Document::parse("theme { mode \"light\"; family \"gruvbox\"; }\n").unwrap();
let document =
ferese_config::Document::parse("theme { mode \"light\"; family \"gruvbox\"; }\n").unwrap();
let candidate = ferese_config::theme::resolve(
&document,
std::path::Path::new("/unused"),
@@ -71,7 +74,10 @@ fn offline_preview_resolves_without_changing_published_values() {
|_| panic!("built-in preview must not read theme files"),
)
.unwrap();
assert_eq!(candidate.theme.appearance, ferese_config::theme::Appearance::Light);
assert_eq!(
candidate.theme.appearance,
ferese_config::theme::Appearance::Light
);
assert_eq!(candidate.theme.tokens.colors.surface_base, "#FBF1C7");
assert_eq!(before, fallback());
}
@@ -79,7 +85,8 @@ fn offline_preview_resolves_without_changing_published_values() {
#[test]
fn complete_snapshots_do_not_resolve_fallbacks_and_future_versions_are_rejected() {
let value = fixture();
let snapshot = Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
let snapshot =
Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
assert_eq!(serde_json::to_value(snapshot).unwrap(), value);
let mut future = value;
future["version"] = json!(ferese_ipc::theme::SCHEMA_VERSION + 1);
+48 -17
View File
@@ -1,14 +1,18 @@
use crate::{Gradient, Paint, PaintStyle, ResolvedTheme};
pub fn rgba(s: &str) -> Result<[f64; 4], String> {
let hex = s.strip_prefix('#').ok_or_else(|| format!("Invalid color: {s}"))?;
let hex = s
.strip_prefix('#')
.ok_or_else(|| format!("Invalid color: {s}"))?;
if !hex.is_ascii() || !matches!(hex.len(), 6 | 8) {
return Err(format!("Invalid color: {s}"));
}
let mut out = [1.; 4];
for (i, c) in out.iter_mut().enumerate().take(hex.len() / 2) {
*c = f64::from(u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| format!("Invalid color: {s}"))?)
/ 255.;
*c = f64::from(
u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|_| format!("Invalid color: {s}"))?,
) / 255.;
}
Ok(out)
}
@@ -74,7 +78,11 @@ pub fn readable(preferred: [f64; 4], background: [f64; 4], minimum: f64) -> [f64
target
}
pub fn readable_across(mut preferred: [f64; 4], backgrounds: &[[f64; 4]], minimum: f64) -> [f64; 4] {
pub fn readable_across(
mut preferred: [f64; 4],
backgrounds: &[[f64; 4]],
minimum: f64,
) -> [f64; 4] {
preferred[3] = 1.;
let score = |color| {
backgrounds
@@ -87,7 +95,11 @@ pub fn readable_across(mut preferred: [f64; 4], backgrounds: &[[f64; 4]], minimu
}
let black = [0., 0., 0., 1.];
let white = [1.; 4];
let target = if score(black) > score(white) { black } else { white };
let target = if score(black) > score(white) {
black
} else {
white
};
for step in 1..=100 {
let color = rgba(&hex(blend(preferred, target, f64::from(step) / 100.))).unwrap();
if score(color) >= minimum {
@@ -99,7 +111,10 @@ pub fn readable_across(mut preferred: [f64; 4], backgrounds: &[[f64; 4]], minimu
pub fn material_bounds(mut surface: [f64; 4], opacity: f64) -> [[f64; 4]; 2] {
surface[3] = opacity;
[composite(surface, [0., 0., 0., 1.]), composite(surface, [1.; 4])]
[
composite(surface, [0., 0., 0., 1.]),
composite(surface, [1.; 4]),
]
}
fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64; 4] {
@@ -111,7 +126,8 @@ fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64;
(white, backgrounds[1])
};
let result = readable(preferred, worst, 4.5);
let outside = luminance(result) < luminance(backgrounds[0]) || luminance(result) > luminance(backgrounds[1]);
let outside = luminance(result) < luminance(backgrounds[0])
|| luminance(result) > luminance(backgrounds[1]);
if outside
&& backgrounds
.iter()
@@ -189,8 +205,14 @@ impl ResolvedTheme {
let p = p * p * (3. - 2. * p);
let mut frame = to.clone();
let mix = |a: &str, b: &str| hex(blend(rgba(a).unwrap(), rgba(b).unwrap(), p));
frame.tokens.colors.surface_base = mix(&self.tokens.colors.surface_base, &to.tokens.colors.surface_base);
frame.tokens.colors.surface_raised = mix(&self.tokens.colors.surface_raised, &to.tokens.colors.surface_raised);
frame.tokens.colors.surface_base = mix(
&self.tokens.colors.surface_base,
&to.tokens.colors.surface_base,
);
frame.tokens.colors.surface_raised = mix(
&self.tokens.colors.surface_raised,
&to.tokens.colors.surface_raised,
);
frame.tokens.colors.application_background = mix(
&self.tokens.colors.application_background,
&to.tokens.colors.application_background,
@@ -212,14 +234,16 @@ impl ResolvedTheme {
&to.tokens.colors.border,
p,
);
frame.tokens.surface.bar.background =
mix(&self.tokens.surface.bar.background, &to.tokens.surface.bar.background);
frame.tokens.material.opacity =
self.tokens.material.opacity + (to.tokens.material.opacity - self.tokens.material.opacity) * p;
frame.tokens.surface.bar.background = mix(
&self.tokens.surface.bar.background,
&to.tokens.surface.bar.background,
);
frame.tokens.material.opacity = self.tokens.material.opacity
+ (to.tokens.material.opacity - self.tokens.material.opacity) * p;
frame.tokens.material.tint_strength = self.tokens.material.tint_strength
+ (to.tokens.material.tint_strength - self.tokens.material.tint_strength) * p;
frame.tokens.shadow.soft.opacity =
self.tokens.shadow.soft.opacity + (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p;
frame.tokens.shadow.soft.opacity = self.tokens.shadow.soft.opacity
+ (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p;
let preferred = if p < 0.5 { self } else { to };
frame.appearance = preferred.appearance;
if self.appearance == to.appearance {
@@ -228,7 +252,11 @@ impl ResolvedTheme {
rgba(&frame.tokens.colors.surface_raised).unwrap(),
rgba(&frame.tokens.colors.application_background).unwrap(),
];
let minimum = if to.accessibility.increase_contrast { 7.0 } else { 4.5 };
let minimum = if to.accessibility.increase_contrast {
7.0
} else {
4.5
};
frame.tokens.colors.text_primary = hex(readable_across(
rgba(&preferred.tokens.colors.text_primary).unwrap(),
&body,
@@ -264,7 +292,10 @@ impl ResolvedTheme {
.into_iter()
.map(luminance)
.fold(f64::INFINITY, f64::min);
let maximum = [base, raised, app].into_iter().map(luminance).fold(0., f64::max);
let maximum = [base, raised, app]
.into_iter()
.map(luminance)
.fold(0., f64::max);
if ((minimum + 0.05) / 0.05).max(1.05 / (maximum + 0.05)) < 4.5 {
frame.tokens.colors.surface_raised = frame.tokens.colors.surface_base.clone();
frame.tokens.colors.application_background = frame.tokens.colors.surface_base.clone();
+5 -1
View File
@@ -155,7 +155,11 @@ pub struct ResolvedTheme {
}
/// Accessibility takes precedence over a panel's authored opacity.
pub fn effective_panel_opacity(inherited: f32, custom: Option<f32>, reduce_transparency: bool) -> f32 {
pub fn effective_panel_opacity(
inherited: f32,
custom: Option<f32>,
reduce_transparency: bool,
) -> f32 {
if reduce_transparency {
1.0
} else {
+5 -1
View File
@@ -13,7 +13,11 @@ pub(crate) enum State {
Disabled,
}
pub(crate) fn accent_button_style(theme: &cosmic::Theme, state: State, focused: bool) -> button::Style {
pub(crate) fn accent_button_style(
theme: &cosmic::Theme,
state: State,
focused: bool,
) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
+49 -22
View File
@@ -61,7 +61,11 @@ fn grid_with_density<'a, M: Clone + 'a>(
navigation: [Element<'a, M>; 3],
compact: bool,
) -> Element<'a, M> {
let cell_width = if compact { Length::Fill } else { Length::Fixed(30.) };
let cell_width = if compact {
Length::Fill
} else {
Length::Fixed(30.)
};
let cell_height = if compact { 20. } else { 25. };
let spacing = if compact { 2. } else { 3. };
let months = [
@@ -96,12 +100,17 @@ fn grid_with_density<'a, M: Clone + 'a>(
.spacing(2);
let mut grid = column::with_capacity(8)
.spacing(spacing)
.width(if compact { Length::Fill } else { Length::Shrink })
.width(if compact {
Length::Fill
} else {
Length::Shrink
})
.push(header);
let mut weekdays =
row::with_capacity(7)
.spacing(spacing)
.width(if compact { Length::Fill } else { Length::Shrink });
let mut weekdays = row::with_capacity(7).spacing(spacing).width(if compact {
Length::Fill
} else {
Length::Shrink
});
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(
@@ -115,27 +124,33 @@ fn grid_with_density<'a, M: Clone + 'a>(
}
grid = grid.push(weekdays);
for week in 0..(start + days).div_ceil(7) {
let mut dates =
row::with_capacity(7)
.spacing(spacing)
.width(if compact { Length::Fill } else { Length::Shrink });
let mut dates = row::with_capacity(7).spacing(spacing).width(if compact {
Length::Fill
} else {
Length::Shrink
});
for weekday in 0..7 {
let cell = week * 7 + weekday;
let day = cell.checked_sub(start).map(|day| day + 1).filter(|day| *day <= days);
let is_today = year == today.year() && month == today.month() && day == Some(today.day() as usize);
let day = cell
.checked_sub(start)
.map(|day| day + 1)
.filter(|day| *day <= days);
let is_today =
year == today.year() && month == today.month() && day == Some(today.day() as usize);
let label = day.map_or(String::new(), |day| day.to_string());
dates = dates.push(
container(crate::text(label, font).size(12).class(theme::Text::Color(if is_today {
primary
} else {
muted
})))
container(
crate::text(label, font)
.size(12)
.class(theme::Text::Color(if is_today { primary } else { muted })),
)
.width(cell_width)
.height(cell_height)
.center_x(cell_width)
.center_y(cell_height)
.class(theme::Container::custom(move |_| container::Style {
background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
background: is_today
.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
border: Border {
shape: BorderShape::Continuous,
radius: palette.radius.min(12.).into(),
@@ -155,9 +170,18 @@ mod tests {
#[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [
("2026-09-28T21:32:00+01:00[Africa/Lagos]", "28 sept, 9:32 pm"),
("2026-09-28T00:05:00+01:00[Africa/Lagos]", "28 sept, 12:05 am"),
("2026-09-28T12:00:00+01:00[Africa/Lagos]", "28 sept, 12:00 pm"),
(
"2026-09-28T21:32:00+01:00[Africa/Lagos]",
"28 sept, 9:32 pm",
),
(
"2026-09-28T00:05:00+01:00[Africa/Lagos]",
"28 sept, 12:05 am",
),
(
"2026-09-28T12:00:00+01:00[Africa/Lagos]",
"28 sept, 12:00 pm",
),
] {
assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected);
}
@@ -169,7 +193,10 @@ mod tests {
let (first, start, days) = super::month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = super::month(february, -2);
assert_eq!((first.year(), first.month(), start, days), (2023, 12, 4, 31));
assert_eq!(
(first.year(), first.month(), start, days),
(2023, 12, 4, 31)
);
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = super::month(june, 0);
assert_eq!((start, days), (6, 30));
+41 -9
View File
@@ -76,12 +76,27 @@ pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(composite(component.hover.into(), surface), base, on));
component.pressed = rgba(readable_fill(composite(component.pressed.into(), surface), base, on));
component.selected = rgba(readable_fill(composite(component.selected.into(), surface), base, on));
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(composite(component.selected.into(), surface), on));
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
@@ -125,7 +140,10 @@ mod tests {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(foreground(Color::from_rgb8(61, 123, 230), preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
}
#[test]
@@ -153,7 +171,12 @@ mod tests {
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [State::Active, State::Hovered, State::Pressed, State::Disabled] {
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = accent_button_style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
@@ -201,11 +224,20 @@ mod tests {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(contrast(component.selected.into(), component.selected_text.into()) >= 4.5);
assert!(contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5);
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [State::Active, State::Hovered, State::Pressed, State::Disabled] {
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let style = accent_button_style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
+36 -7
View File
@@ -91,7 +91,11 @@ pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> (
let track = if enabled {
palette.accent
} else {
mix(palette.card, palette.text, if hovered { 0.18 } else { 0.12 })
mix(
palette.card,
palette.text,
if hovered { 0.18 } else { 0.12 },
)
};
let thumb = crate::foreground(track, palette.text);
(track, thumb)
@@ -148,7 +152,13 @@ pub fn select<'a, M: 'a>(
}))
}
fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, focus_visible: bool) -> theme::Button {
fn styled_button(
p: Palette,
selected: bool,
navigation: bool,
opacity: f32,
focus_visible: bool,
) -> theme::Button {
let style = move |hover: bool, focused: bool| {
let background = if selected {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
@@ -177,7 +187,12 @@ fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, foc
};
let fill = if selected {
p.selected_background(if hover { 0.24 } else { 0.17 }, background.a, on, background)
p.selected_background(
if hover { 0.24 } else { 0.17 },
background.a,
on,
background,
)
} else {
Background::Color(background)
};
@@ -220,7 +235,10 @@ pub fn authentication_button(palette: Palette) -> theme::Button {
let candidate = mix(palette.accent, palette.text, interaction);
crate::accent_pair(crate::composite(candidate, palette.card), palette.text)
} else {
(crate::composite(palette.card, palette.background), palette.muted)
(
crate::composite(palette.card, palette.background),
palette.muted,
)
};
button::Style {
shape: Some(BorderShape::Continuous),
@@ -275,7 +293,11 @@ pub fn authentication_input(palette: Palette) -> theme::TextInput {
}
}
pub fn lock_input(radius: f32, accent: cosmic::iced::Color, surface: cosmic::iced::Color) -> theme::TextInput {
pub fn lock_input(
radius: f32,
accent: cosmic::iced::Color,
surface: cosmic::iced::Color,
) -> theme::TextInput {
let appearance = move |focused: bool| widget::text_input::Appearance {
shape: Some(BorderShape::Continuous),
outline: None,
@@ -329,7 +351,9 @@ pub fn shell_button(
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 }) + progress * if selected { 0.02 } else { 0.08 }) * opacity
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
},
..foreground
})),
@@ -349,7 +373,12 @@ pub fn surface_appearance(background: Color, radius: f32) -> container::Style {
}
}
pub fn notification_button(foreground: Color, hover: Color, radius: f32, filled: bool) -> theme::Button {
pub fn notification_button(
foreground: Color,
hover: Color,
radius: f32,
filled: bool,
) -> theme::Button {
let style = move |active: bool| button::Style {
shape: Some(BorderShape::Continuous),
outline: None,
+98 -36
View File
@@ -38,11 +38,14 @@ pub fn tile<M: Clone + 'static>(
.or(family.dark.as_ref())
.map(|palette| crate::parse_color(&palette.text).unwrap())
.unwrap_or(palette.text);
let mut caption = row([]).spacing(6).align_y(cosmic::iced::Alignment::Center).push(
crate::text(family.name.clone(), font)
.size(12)
.class(cosmic::theme::Text::Color(label_color)),
);
let mut caption = row([])
.spacing(6)
.align_y(cosmic::iced::Alignment::Center)
.push(
crate::text(family.name.clone(), font)
.size(12)
.class(cosmic::theme::Text::Color(label_color)),
);
if family.light.is_none() || family.dark.is_none() {
caption = caption.push(
crate::text(
@@ -129,14 +132,21 @@ fn preview_with_radius(
radius: f32,
) -> cosmic::widget::icon::Icon {
use cosmic::widget::icon;
let key = format!("{}/{variant:?}/{active:?}/{selected}/{}", family.id, radius.to_bits());
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, (Family, icon::Handle)>>> =
std::sync::OnceLock::new();
let key = format!(
"{}/{variant:?}/{active:?}/{selected}/{}",
family.id,
radius.to_bits()
);
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, (Family, icon::Handle)>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut cache = cache.lock().unwrap();
if cache.get(&key).is_none_or(|(cached, _)| cached != family) {
let handle = icon::from_svg_bytes(svg_with_radius(family, variant, active, selected, radius).into_bytes())
.symbolic(false);
let handle = icon::from_svg_bytes(
svg_with_radius(family, variant, active, selected, radius).into_bytes(),
)
.symbolic(false);
cache.insert(key.clone(), (family.clone(), handle));
}
let handle = cache[&key].1.clone();
@@ -164,7 +174,12 @@ fn desktop(p: &Palette) -> String {
)
}
pub fn svg(family: &Family, variant: Option<Appearance>, active: Option<Appearance>, selected: bool) -> String {
pub fn svg(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
) -> String {
svg_with_radius(family, variant, active, selected, 10.)
}
@@ -180,9 +195,12 @@ fn svg_with_radius(
<clipPath id="light"><rect width="100" height="126"/></clipPath><clipPath id="dark"><rect x="100" width="100" height="126"/></clipPath>
<pattern id="missing" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="#D9DDE2"/><path d="M-2 2l4-4M0 8l8-8M6 10l4-4" stroke="#C4C9D1"/></pattern>"##,
);
let outline =
cosmic::iced::border::Outline::new([0., 0., 200., 126.], [f64::from(radius); 4], BorderShape::Continuous)
.expect("finite preview dimensions");
let outline = cosmic::iced::border::Outline::new(
[0., 0., 200., 126.],
[f64::from(radius); 4],
BorderShape::Continuous,
)
.expect("finite preview dimensions");
// Rasterized once per cached preview; no shaped rendering layer per tile.
let points = outline.polygon(0.02).expect("positive contour tolerance");
result.push_str("<clipPath id=\"preview\"><polygon points=\"");
@@ -338,7 +356,11 @@ fn radio_with_tab_stop<'a, M: Clone + 'a>(
})
}
pub fn group<'a, M: Clone + 'a>(content: impl Into<Element<'a, M>>, id: Id, label: &str) -> Element<'a, M> {
pub fn group<'a, M: Clone + 'a>(
content: impl Into<Element<'a, M>>,
id: Id,
label: &str,
) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
@@ -366,18 +388,36 @@ impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation<()>) {
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation<()>,
) {
if self.group {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
} else if self.tab_stop {
operation.focusable(Some(&self.id), layout.bounds(), tree.state.downcast_mut::<Focus>());
operation.focusable(
Some(&self.id),
layout.bounds(),
tree.state.downcast_mut::<Focus>(),
);
} else {
tree.state.downcast_mut::<Focus>().0 = false;
}
@@ -398,19 +438,25 @@ impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
if !iced_accessibility::IdEq::eq(&id.0, &self.id.0) {
return;
}
if matches!(request.action, accesskit::Action::Focus | accesskit::Action::Click)
&& let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
if matches!(
request.action,
accesskit::Action::Focus | accesskit::Action::Click
) && let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
}
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))) {
tree.state.downcast_mut::<Focus>().0 = self.tab_stop && cursor.is_over(layout.bounds());
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
tree.state.downcast_mut::<Focus>().0 =
self.tab_stop && cursor.is_over(layout.bounds());
}
}
if !self.group && self.tab_stop && tree.state.downcast_ref::<Focus>().0 {
@@ -460,9 +506,15 @@ impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
if !self.group && self.tab_stop && tree.state.downcast_ref::<Focus>().0 {
use renderer::Renderer as _;
renderer.fill_quad(
@@ -489,16 +541,26 @@ impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn a11y_nodes(&self, layout: Layout<'_>, tree: &Tree, cursor: mouse::Cursor) -> A11yTree {
let children = self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor);
let children = self
.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor);
if self.group {
let mut node = accesskit::Node::new(accesskit::Role::RadioGroup);
node.set_label(self.label.clone());
A11yTree::node_with_child_tree(iced_accessibility::A11yNode::new(node, self.id.clone()), children)
A11yTree::node_with_child_tree(
iced_accessibility::A11yNode::new(node, self.id.clone()),
children,
)
} else {
let mut children = children;
for node in children.root_mut() {
+29 -9
View File
@@ -42,7 +42,8 @@ pub const BATTERY_75: &[u8] = include_bytes!("../assets/icons/status/battery-75.
pub const BATTERY_CHARGING: &[u8] = include_bytes!("../assets/icons/status/battery-charging.svg");
pub const BATTERY_EMPTY: &[u8] = include_bytes!("../assets/icons/status/battery-empty.svg");
pub const BATTERY_FULL: &[u8] = include_bytes!("../assets/icons/status/battery-full.svg");
pub const BLUETOOTH_CONNECTED: &[u8] = include_bytes!("../assets/icons/status/bluetooth-connected.svg");
pub const BLUETOOTH_CONNECTED: &[u8] =
include_bytes!("../assets/icons/status/bluetooth-connected.svg");
pub const BLUETOOTH_OFF: &[u8] = include_bytes!("../assets/icons/status/bluetooth-off.svg");
pub const BLUETOOTH_ON: &[u8] = include_bytes!("../assets/icons/status/bluetooth-on.svg");
pub const BRIGHTNESS: &[u8] = include_bytes!("../assets/icons/status/brightness.svg");
@@ -95,7 +96,10 @@ pub fn tinted(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon
}
pub fn symbolic(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source).symbolic(true).icon().size(size)
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
}
const TINT_CACHE_ENTRIES: usize = 64;
@@ -141,7 +145,8 @@ impl TintCache {
let bytes = svg.capacity();
let handle = icon::from_svg_bytes(svg).symbolic(false);
if bytes <= TINT_CACHE_BYTES {
while self.entries.len() >= TINT_CACHE_ENTRIES || self.bytes + bytes > TINT_CACHE_BYTES {
while self.entries.len() >= TINT_CACHE_ENTRIES || self.bytes + bytes > TINT_CACHE_BYTES
{
self.bytes -= self.entries.pop_front().unwrap().bytes;
}
self.bytes += bytes;
@@ -176,8 +181,8 @@ pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Col
.size(size)
.width(Length::Fixed(f32::from(size) * aspect))
.content_fit(ContentFit::Contain)
.class(theme::Svg::custom(move |_| cosmic::iced::widget::svg::Style {
color: None,
.class(theme::Svg::custom(move |_| {
cosmic::iced::widget::svg::Style { color: None }
}))
}
@@ -232,10 +237,22 @@ mod tests {
};
let first = cache.get(super::BATTERY_50, foreground, accent);
let second = cache.get(super::BATTERY_50, foreground, accent);
assert_eq!(bytes(&first), bytes(&second), "rebuilds must reuse the same allocation");
let changed_accent = cache.get(super::BATTERY_50, foreground, Color::from_rgb8(42, 90, 180));
assert_eq!(
bytes(&first),
bytes(&second),
"rebuilds must reuse the same allocation"
);
let changed_accent =
cache.get(super::BATTERY_50, foreground, Color::from_rgb8(42, 90, 180));
assert_ne!(bytes(&first), bytes(&changed_accent));
let translucent = cache.get(super::BATTERY_50, Color { a: 0.5, ..foreground }, accent);
let translucent = cache.get(
super::BATTERY_50,
Color {
a: 0.5,
..foreground
},
accent,
);
assert_ne!(bytes(&first), bytes(&translucent));
assert_eq!(cache.entries.len(), 3);
}
@@ -252,7 +269,10 @@ mod tests {
assert!(cache.entries.len() <= super::TINT_CACHE_ENTRIES);
assert!(cache.bytes <= super::TINT_CACHE_BYTES);
}
assert_eq!(cache.entries.back().unwrap().colors[3], (511_f32 / 512.).to_bits());
assert_eq!(
cache.entries.back().unwrap().colors[3],
(511_f32 / 512.).to_bits()
);
}
#[test]
+30 -8
View File
@@ -19,9 +19,12 @@ pub fn row<'a, M: 'a>(
trailing: Element<'a, M>,
font: cosmic::font::Font,
) -> Element<'a, M> {
iced_row![crate::text(label, font).width(Length::Fill).size(14), trailing]
.align_y(Alignment::Center)
.into()
iced_row![
crate::text(label, font).width(Length::Fill).size(14),
trailing
]
.align_y(Alignment::Center)
.into()
}
pub fn heading<'a, M: 'a>(
@@ -34,7 +37,12 @@ pub fn heading<'a, M: 'a>(
.align_y(Alignment::Center)
}
pub fn badge<'a, M: 'a>(content: Element<'a, M>, accent: Color, opacity: f32, radius: f32) -> Element<'a, M> {
pub fn badge<'a, M: 'a>(
content: Element<'a, M>,
accent: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
container(content)
.width(36)
.height(36)
@@ -47,7 +55,12 @@ pub fn badge<'a, M: 'a>(content: Element<'a, M>, accent: Color, opacity: f32, ra
.into()
}
pub fn section<'a, M: 'a>(content: Element<'a, M>, foreground: Color, opacity: f32, radius: f32) -> Element<'a, M> {
pub fn section<'a, M: 'a>(
content: Element<'a, M>,
foreground: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
container(content)
.padding(10)
.width(Length::Fill)
@@ -73,7 +86,10 @@ pub fn section_label<'a>(
pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
container(cosmic::widget::Space::new().width(Length::Fill).height(1))
.class(crate::controls::surface(foreground.scale_alpha(0.12 * opacity), 0.))
.class(crate::controls::surface(
foreground.scale_alpha(0.12 * opacity),
0.,
))
.into()
}
@@ -85,7 +101,10 @@ pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -
.width(16)
.height(16)
.class(crate::controls::surface(
Color { a: opacity, ..thumb },
Color {
a: opacity,
..thumb
},
crate::inner_radius(track_radius, padding),
));
container(knob)
@@ -97,7 +116,10 @@ pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -
} else {
cosmic::iced::alignment::Horizontal::Left
})
.class(crate::controls::surface(track.scale_alpha(opacity), track_radius))
.class(crate::controls::surface(
track.scale_alpha(opacity),
track_radius,
))
.into()
}
+43 -14
View File
@@ -27,12 +27,16 @@ impl Palette {
sidebar: color(&theme.tokens.colors.surface_base),
card: color(&theme.tokens.colors.surface_raised),
accent: color(&theme.tokens.colors.accent),
accent_gradient: theme.tokens.focus_ring.effective_gradient().map(|gradient| {
// Iced's zero angle points up; theme angles start left-to-right.
Linear::new((gradient.angle.rem_euclid(360.) as f32 + 90.).to_radians())
.add_stop(0., color(&gradient.from))
.add_stop(1., color(&gradient.to))
}),
accent_gradient: theme
.tokens
.focus_ring
.effective_gradient()
.map(|gradient| {
// Iced's zero angle points up; theme angles start left-to-right.
Linear::new((gradient.angle.rem_euclid(360.) as f32 + 90.).to_radians())
.add_stop(0., color(&gradient.from))
.add_stop(1., color(&gradient.to))
}),
on_accent: color(&theme.tokens.colors.on_accent),
text: color(&theme.tokens.colors.text_primary),
muted: color(&theme.tokens.colors.text_muted),
@@ -63,13 +67,19 @@ impl Palette {
};
for stop in gradient.stops.iter_mut().flatten() {
stop.color = mix(self.sidebar, crate::composite(stop.color, self.sidebar), strength);
stop.color = mix(
self.sidebar,
crate::composite(stop.color, self.sidebar),
strength,
);
}
// Bound every interpolated color, rather than sampling a few points.
// Adjust both stops together so one foreground stays readable.
let minimum = if self.high_contrast { 7. } else { 4.5 };
let shade = if crate::contrast(Color::BLACK, foreground) > crate::contrast(Color::WHITE, foreground) {
let shade = if crate::contrast(Color::BLACK, foreground)
> crate::contrast(Color::WHITE, foreground)
{
Color::BLACK
} else {
Color::WHITE
@@ -153,7 +163,11 @@ pub fn parse_color(value: &str) -> Option<Color> {
}
pub fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t)
Color::from_rgb(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
)
}
pub fn surface_shade(base: Color) -> Color {
@@ -223,14 +237,23 @@ mod tests {
let native = palette.native_theme();
for opacity in [1., 0.5] {
let class = crate::controls::material_button_style(palette, true, opacity);
for style in [native.active(false, true, &class), native.hovered(false, true, &class)] {
let Some(Background::Gradient(cosmic::iced::Gradient::Linear(gradient))) = style.background else {
for style in [
native.active(false, true, &class),
native.hovered(false, true, &class),
] {
let Some(Background::Gradient(cosmic::iced::Gradient::Linear(gradient))) =
style.background
else {
panic!("{} selected control lost its gradient", preset.id);
};
let from = gradient.stops[0].unwrap().color;
let to = gradient.stops[1].unwrap().color;
assert_eq!(from.a, to.a);
assert!(if opacity == 1. { from.a == 1. } else { from.a < 1. });
assert!(if opacity == 1. {
from.a == 1.
} else {
from.a < 1.
});
for step in 0..=32 {
let fill = crate::composite(
Color {
@@ -239,7 +262,11 @@ mod tests {
},
palette.sidebar,
);
assert!(crate::contrast(fill, style.text_color.unwrap()) >= 4.5, "{}", preset.id);
assert!(
crate::contrast(fill, style.text_color.unwrap()) >= 4.5,
"{}",
preset.id
);
}
}
}
@@ -257,7 +284,9 @@ mod tests {
let mut resolved = ferese_config::theme::default_theme();
resolved.tokens.focus_ring.gradient.as_mut().unwrap().angle = 0.;
let palette = Palette::from_resolved(&resolved);
assert!((palette.accent_gradient.unwrap().angle.0 - std::f32::consts::FRAC_PI_2).abs() < 0.001);
assert!(
(palette.accent_gradient.unwrap().angle.0 - std::f32::consts::FRAC_PI_2).abs() < 0.001
);
resolved.tokens.focus_ring.style = ferese_theme_model::PaintStyle::Solid;
assert!(Palette::from_resolved(&resolved).accent_gradient.is_none());
resolved.tokens.focus_ring.style = ferese_theme_model::PaintStyle::Auto;
+78 -34
View File
@@ -89,7 +89,11 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
}
fn children(&self) -> Vec<widget::Tree> {
std::iter::once(widget::Tree::new(&self.content))
.chain(self.samples.iter().map(|sample| widget::Tree::new(&sample.view)))
.chain(
self.samples
.iter()
.map(|sample| widget::Tree::new(&sample.view)),
)
.collect()
}
fn diff(&mut self, tree: &mut widget::Tree) {
@@ -117,15 +121,22 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
let mut measurements: Vec<Measurement> = Vec::new();
// Measure preferred content before imposing the panel's width. Otherwise
// a clipped wide control can look as small as its compact alternative.
let measurement_limits = layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, limits.max().height));
let measurement_limits =
layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, limits.max().height));
for (index, sample) in self.samples.iter_mut().enumerate() {
let node = sample
.view
.as_widget_mut()
.layout(&mut tree.children[index + 1], renderer, &measurement_limits);
let node = sample.view.as_widget_mut().layout(
&mut tree.children[index + 1],
renderer,
&measurement_limits,
);
let width = node.size().width;
if let Some(measurement) = measurements.iter_mut().find(|measurement| measurement.id == sample.id) {
measurement.alternatives.push((sample.representation, width));
if let Some(measurement) = measurements
.iter_mut()
.find(|measurement| measurement.id == sample.id)
{
measurement
.alternatives
.push((sample.representation, width));
} else {
measurements.push(Measurement {
id: sample.id.clone(),
@@ -162,7 +173,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn update(
&mut self,
@@ -186,8 +199,10 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
viewport,
);
let state = tree.state.downcast_mut::<State>();
if matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_)))
&& state.published.as_ref() != Some(&self.resolution)
if matches!(
event,
Event::Window(cosmic::iced::window::Event::RedrawRequested(_))
) && state.published.as_ref() != Some(&self.resolution)
{
state.published = Some(self.resolution.clone());
shell.publish((self.changed)(self.resolution.clone()));
@@ -203,9 +218,15 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
@@ -215,9 +236,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
@@ -238,9 +263,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -294,9 +323,10 @@ mod tests {
for opacity in [0., 0.5, 1.] {
for border in [false, true] {
renderer.reset(bounds);
let group = cosmic::widget::container(widgets::Space::new().width(40).height(40)).class(
cosmic::theme::Container::custom(move |_| group_border(Color::WHITE, 8., border)),
);
let group = cosmic::widget::container(widgets::Space::new().width(40).height(40))
.class(cosmic::theme::Container::custom(move |_| {
group_border(Color::WHITE, 8., border)
}));
let mut view: Element<'_, ()> = cosmic::widget::container(group)
.class(cosmic::theme::Container::custom(move |_| {
crate::controls::surface_appearance(
@@ -309,9 +339,11 @@ mod tests {
}))
.into();
let mut tree = widget::Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
view.as_widget().draw(
&tree,
&mut renderer,
@@ -325,7 +357,8 @@ mod tests {
mouse::Cursor::Unavailable,
&bounds,
);
let pixels = Headless::screenshot(&mut renderer, Size::new(40, 40), 1., Color::TRANSPARENT);
let pixels =
Headless::screenshot(&mut renderer, Size::new(40, 40), 1., Color::TRANSPARENT);
let alpha = pixels[(20 * 40 + 20) * 4 + 3];
assert!(
i16::from(alpha).abs_diff((opacity * 255.).round() as i16) <= 1,
@@ -370,10 +403,12 @@ mod tests {
id: id.clone(),
representation,
minimum: None,
view: button::custom(text(label).wrapping(cosmic::iced::widget::text::Wrapping::None))
.height(28)
.on_press(Probe::MeasurementClick)
.into(),
view: button::custom(
text(label).wrapping(cosmic::iced::widget::text::Wrapping::None),
)
.height(28)
.on_press(Probe::MeasurementClick)
.into(),
})
.chain(std::iter::once(Sample {
id: ItemId("_overflow".into()),
@@ -413,14 +448,23 @@ mod tests {
let limits = layout::Limits::new(Size::ZERO, viewport.size());
let node = view.as_widget_mut().layout(tree, &renderer, &limits);
let built = builds.get();
assert_eq!(built, 2, "initial measurement must replace the unallocated content");
assert_eq!(
built, 2,
"initial measurement must replace the unallocated content"
);
view.as_widget_mut().layout(tree, &renderer, &limits);
assert_eq!(builds.get(), built, "unchanged allocation must reuse its controls");
assert_eq!(
builds.get(),
built,
"unchanged allocation must reuse its controls"
);
let mut messages = Vec::new();
for _ in 0..4 {
view.as_widget_mut().update(
tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
&Event::Window(cosmic::iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
+125 -60
View File
@@ -86,7 +86,12 @@ impl widget::Operation for Collect<'_> {
}
impl<M> Surface<'_, M> {
fn measure(&mut self, tree: &mut widget::Tree, layout: Layout<'_>, renderer: &cosmic::Renderer) {
fn measure(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
) {
let mut collector = Collect {
targets: &self.options.groups,
origin: layout.bounds().position(),
@@ -94,17 +99,20 @@ impl<M> Surface<'_, M> {
bounds_id: self.options.bounds.as_ref(),
bounds: None,
};
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut collector);
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut collector,
);
let size = layout.bounds().size();
let scene = PixelGrid::new(self.options.scale)
.and_then(|grid| {
ferese_panel::scene::derive(
self.options.parameters,
collector
.bounds
.unwrap_or_else(|| ferese_layout::Rect::new(0., 0., size.width.into(), size.height.into())),
collector.bounds.unwrap_or_else(|| {
ferese_layout::Rect::new(0., 0., size.width.into(), size.height.into())
}),
&collector.groups,
grid,
)
@@ -162,7 +170,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn update(
&mut self,
@@ -185,7 +195,10 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
shell,
viewport,
);
if matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) && !shell.is_layout_invalid()
if matches!(
event,
Event::Window(cosmic::iced::window::Event::RedrawRequested(_))
) && !shell.is_layout_invalid()
{
self.measure(tree, layout, renderer);
(self.present)(&tree.state.downcast_ref::<State>().scene);
@@ -250,7 +263,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
};
frame.stroke(
&Path::line(Point::new(0., y), Point::new(bounds.width, y)),
Stroke::default().with_color(self.options.border).with_width(width),
Stroke::default()
.with_color(self.options.border)
.with_width(width),
);
} else if self.options.parameters.style == ferese_panel::PanelStyle::Notch
|| (self.options.parameters.style == ferese_panel::PanelStyle::Morph
@@ -264,30 +279,45 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
let border = Path::new(|builder| {
for contour in contours {
if let Some((first, rest)) = contour.split_first() {
builder.move_to(Point::new(first[0] as f32, first[1] as f32));
builder.move_to(Point::new(
first[0] as f32,
first[1] as f32,
));
for point in rest {
builder.line_to(Point::new(point[0] as f32, point[1] as f32));
builder.line_to(Point::new(
point[0] as f32,
point[1] as f32,
));
}
}
}
});
frame.stroke(
&border,
Stroke::default().with_color(self.options.border).with_width(width),
Stroke::default()
.with_color(self.options.border)
.with_width(width),
);
}
} else {
frame.stroke(
&path,
Stroke::default().with_color(self.options.border).with_width(width),
Stroke::default()
.with_color(self.options.border)
.with_width(width),
);
}
}
}
for [x1, y1, x2, y2] in &state.scene.dividers {
frame.stroke(
&Path::line(Point::new(*x1 as f32, *y1 as f32), Point::new(*x2 as f32, *y2 as f32)),
Stroke::default().with_color(self.options.divider).with_width(hairline),
&Path::line(
Point::new(*x1 as f32, *y1 as f32),
Point::new(*x2 as f32, *y2 as f32),
),
Stroke::default()
.with_color(self.options.divider)
.with_width(hairline),
);
}
});
@@ -300,9 +330,15 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
// Keep content after cached vector geometry in the renderer's layer order.
// Parent-layer text can otherwise be batched underneath the background.
renderer.with_layer(*viewport, |renderer| {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
}
fn mouse_interaction(
@@ -313,9 +349,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
@@ -336,9 +376,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -353,11 +397,9 @@ mod tests {
fn shared_surface_publishes_layout_changes_and_leaves_native_fill_to_the_compositor() {
use cosmic::iced::advanced::clipboard;
use std::cell::RefCell;
let mut renderer = cosmic::iced::futures::executor::block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
let mut renderer = cosmic::iced::futures::executor::block_on(
<cosmic::Renderer as Headless>::new(Font::default(), Pixels(14.), Some("tiny-skia")),
)
.unwrap();
let ids = [widget::Id::unique(), widget::Id::unique()];
let mut tree = None;
@@ -379,7 +421,10 @@ mod tests {
.spacing(20);
let published = RefCell::new(Vec::new());
let mut view = frame(
cosmic::widget::container(content).width(200).height(32).into(),
cosmic::widget::container(content)
.width(200)
.height(32)
.into(),
Options {
parameters: Parameters {
style,
@@ -403,14 +448,18 @@ mod tests {
);
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(&mut view);
let node = view
.as_widget_mut()
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let mut messages = Vec::new();
for _ in 0..3 {
view.as_widget_mut().update(
tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
&Event::Window(cosmic::iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
@@ -425,9 +474,15 @@ mod tests {
usize::from(!(style == Style::Floating && width == 40. && native))
);
let scene = published.borrow()[0].clone();
assert_eq!(scene.input.len(), if style == Style::Floating { 2 } else { 1 });
assert_eq!(
scene.input.len(),
if style == Style::Floating { 2 } else { 1 }
);
if style == Style::Floating {
assert!(scene.distance([width as f64 + 10., 16.]) > 0., "the gap stays empty");
assert!(
scene.distance([width as f64 + 10., 16.]) > 0.,
"the gap stays empty"
);
}
renderer.reset(viewport);
view.as_widget().draw(
@@ -450,11 +505,9 @@ mod tests {
#[test]
fn border_coverage_tracks_physical_scale_and_high_contrast() {
let mut renderer = cosmic::iced::futures::executor::block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
let mut renderer = cosmic::iced::futures::executor::block_on(
<cosmic::Renderer as Headless>::new(Font::default(), Pixels(14.), Some("tiny-skia")),
)
.unwrap();
for scale in [0.75, 1., 1.25, 1.5, 2.] {
for high_contrast in [false, true] {
@@ -480,9 +533,11 @@ mod tests {
);
let mut tree = widget::Tree::new(&view);
let bounds = Rectangle::with_size(Size::new(64., 32.));
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
view.as_widget().draw(
&tree,
@@ -500,7 +555,10 @@ mod tests {
let size = Size::new((64. * scale) as u32, (32. * scale) as u32);
let pixels = renderer.screenshot(size, scale as f32, Color::TRANSPARENT);
let coverage: f64 = (0..size.height)
.map(|y| f64::from(pixels[((y * size.width + size.width / 2) * 4 + 3) as usize]) / 255.)
.map(|y| {
f64::from(pixels[((y * size.width + size.width / 2) * 4 + 3) as usize])
/ 255.
})
.sum();
let expected = if high_contrast { scale.max(1.) } else { 1. };
assert!(
@@ -513,11 +571,9 @@ mod tests {
#[test]
fn the_surface_background_keeps_parent_layer_text_visible() {
let mut renderer = cosmic::iced::futures::executor::block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
let mut renderer = cosmic::iced::futures::executor::block_on(
<cosmic::Renderer as Headless>::new(Font::default(), Pixels(14.), Some("tiny-skia")),
)
.unwrap();
for scale in [1., 1.25, 1.5] {
let content = cosmic::widget::container(
@@ -547,9 +603,11 @@ mod tests {
);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(150., 34.));
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
renderer.reset(viewport);
view.as_widget().draw(
&tree,
@@ -569,7 +627,10 @@ mod tests {
scale as f32,
Color::BLACK,
);
let ink = pixels.chunks_exact(4).filter(|pixel| pixel[0] > 128).count();
let ink = pixels
.chunks_exact(4)
.filter(|pixel| pixel[0] > 128)
.count();
assert!(
ink > 25,
"clock text was covered by the surface background at scale {scale}"
@@ -605,9 +666,11 @@ mod tests {
let b = widget::Id::unique();
let content = cosmic::widget::container(cosmic::widget::row![
cosmic::widget::Space::new().width(4),
cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16)).id(a.clone()),
cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16))
.id(a.clone()),
cosmic::widget::Space::new().width(4),
cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16)).id(b.clone()),
cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16))
.id(b.clone()),
])
.width(96)
.height(64)
@@ -640,9 +703,11 @@ mod tests {
);
let mut tree = widget::Tree::new(&view);
let bounds = Rectangle::new(Point::ORIGIN, Size::new(96., 64.));
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
view.as_widget().draw(
&tree,
+50 -16
View File
@@ -55,7 +55,11 @@ pub fn indicator<'a, M: 'a>(
) -> Element<'a, M> {
let progress = progress.clamp(0., 1.);
let occupied = workspace.window_count > 0;
let neutral = if occupied { palette.text } else { palette.muted };
let neutral = if occupied {
palette.text
} else {
palette.muted
};
let foreground = crate::mix(neutral, palette.accent, progress);
let content: Element<'a, M> = match style {
WorkspaceStyle::Numbers | WorkspaceStyle::Tabs => {
@@ -66,7 +70,11 @@ pub fn indicator<'a, M: 'a>(
};
crate::text(label, font)
.size(if style == WorkspaceStyle::Tabs { 11 } else { 12 })
.size(if style == WorkspaceStyle::Tabs {
11
} else {
12
})
.width(width(style) - 8.)
.align_x(cosmic::iced::alignment::Horizontal::Center)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
@@ -81,7 +89,11 @@ pub fn indicator<'a, M: 'a>(
.height(6)
.class(theme::Container::custom(move |_| {
let mut paint = crate::controls::surface_appearance(
foreground.scale_alpha(if occupied { 0.65 + 0.35 * progress } else { progress }),
foreground.scale_alpha(if occupied {
0.65 + 0.35 * progress
} else {
progress
}),
3.,
);
paint.border.width = 1.;
@@ -97,15 +109,25 @@ pub fn indicator<'a, M: 'a>(
};
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 26 22\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\">{back}<rect x=\"2\" y=\"7\" width=\"16\" height=\"12\" rx=\"2.5\"/>{}</svg>",
if occupied { "<path d=\"M2 11h16\"/>" } else { "" }
if occupied {
"<path d=\"M2 11h16\"/>"
} else {
""
}
);
cosmic::widget::icon::from_svg_bytes(crate::icons::tint_svg(svg.as_bytes(), foreground, foreground))
.symbolic(false)
.icon()
.size(20)
.class(theme::Svg::custom(|_| cosmic::iced::widget::svg::Style { color: None }))
.into()
cosmic::widget::icon::from_svg_bytes(crate::icons::tint_svg(
svg.as_bytes(),
foreground,
foreground,
))
.symbolic(false)
.icon()
.size(20)
.class(theme::Svg::custom(|_| cosmic::iced::widget::svg::Style {
color: None,
}))
.into()
}
WorkspaceStyle::AppIcons => {
let mut icons = row::with_capacity(2).spacing(3).align_y(Alignment::Center);
@@ -128,7 +150,10 @@ pub fn indicator<'a, M: 'a>(
let marker = container(Space::new())
.width(10)
.height(2)
.class(crate::controls::surface(foreground.scale_alpha(progress), 1.));
.class(crate::controls::surface(
foreground.scale_alpha(progress),
1.,
));
cosmic::iced::widget::stack![
face,
container(marker)
@@ -150,7 +175,8 @@ pub fn indicator<'a, M: 'a>(
Color::TRANSPARENT
} else if progress > 0. {
palette.accent.scale_alpha(0.16 * progress)
} else if style == WorkspaceStyle::Tabs || occupied && style == WorkspaceStyle::Numbers {
} else if style == WorkspaceStyle::Tabs || occupied && style == WorkspaceStyle::Numbers
{
palette.text.scale_alpha(0.045)
} else {
Color::TRANSPARENT
@@ -177,7 +203,8 @@ pub fn sample<'a, M: 'a>(
for (index, count, active) in [(1, 2, true), (2, 1, false), (3, 0, false)] {
let apps = if style == WorkspaceStyle::AppIcons && count > 0 {
let mut apps = vec![crate::icons::tinted(crate::icons::SETTINGS, 16, palette.text).into()];
let mut apps =
vec![crate::icons::tinted(crate::icons::SETTINGS, 16, palette.text).into()];
if count > 1 {
apps.push(crate::icons::tinted(crate::icons::FERESE, 16, palette.accent).into());
@@ -210,7 +237,9 @@ pub fn sample<'a, M: 'a>(
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::{Layout, Renderer, layout, renderer, renderer::Headless, widget::Tree};
use cosmic::iced::advanced::{
Layout, Renderer, layout, renderer, renderer::Headless, widget::Tree,
};
use cosmic::iced::{Font, Pixels, Rectangle, Size, mouse};
#[test]
@@ -241,7 +270,9 @@ mod tests {
(3, 1., "Design and development"),
] {
let apps = (0..count.min(2))
.map(|_| crate::icons::tinted(crate::icons::SETTINGS, 16, palette.text).into())
.map(|_| {
crate::icons::tinted(crate::icons::SETTINGS, 16, palette.text).into()
})
.collect();
let mut view: Element<'_, ()> = indicator(
style,
@@ -314,7 +345,10 @@ mod tests {
active: true,
visible_elsewhere: false,
};
assert_eq!(tooltip_label(&workspace), "Workspace 2 · Design · 1 window · Active");
assert_eq!(
tooltip_label(&workspace),
"Workspace 2 · Design · 1 window · Active"
);
workspace.name = "2".into();
workspace.window_count = 0;
workspace.active = false;