Fix compositor output routing and pointer hints; format imports

This commit is contained in:
Samuel Onoja committed 2026-09-30 06:39:58 +01:00
1 parent 88e4f3c135
commit 46509f880a
108 files changed
+3682 -8371

No files matched your search

+24 -114
View File
@@ -108,13 +108,7 @@ impl AnimatedValue {
for _ in 0..steps {
let previous_error = self.current - self.target;
advance_value(
&mut self.current,
self.target,
&mut self.velocity,
step,
config,
);
advance_value(&mut self.current, self.target, &mut self.velocity, step, config);
let current_error = self.current - self.target;
if previous_error != 0.0 && previous_error.signum() != current_error.signum() {
self.snap();
@@ -163,20 +157,8 @@ impl AnimatedRect {
let step = seconds / steps as f64;
for _ in 0..steps {
advance_value(
&mut self.current.x,
self.target.x,
&mut self.velocity.x,
step,
config,
);
advance_value(
&mut self.current.y,
self.target.y,
&mut self.velocity.y,
step,
config,
);
advance_value(&mut self.current.x, self.target.x, &mut self.velocity.x, step, config);
advance_value(&mut self.current.y, self.target.y, &mut self.velocity.y, step, config);
advance_value(
&mut self.current.width,
self.target.width,
@@ -321,12 +303,7 @@ 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 {
@@ -338,12 +315,7 @@ 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);
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
let mut progress = AnimatedValue::new(0.0);
@@ -359,14 +331,9 @@ impl WindowGeometry {
let speed = [velocity.x, velocity.y, velocity.width, velocity.height];
let squared_length = distance.iter().map(|value| value * value).sum::<f64>();
if squared_length > 0.001 {
let projected =
distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / squared_length;
let projected = distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / squared_length;
// Keep forward momentum; soften a reversal without jumping position.
progress.velocity = if projected < 0.0 {
projected * 0.35
} else {
projected
};
progress.velocity = if projected < 0.0 { projected * 0.35 } else { projected };
}
self.zoom = Some(ZoomTransition {
from: self.visual.current,
@@ -412,24 +379,14 @@ 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 };
@@ -506,15 +463,8 @@ impl WindowGeometry {
}
}
fn advance_value(
current: &mut f64,
target: f64,
velocity: &mut f64,
delta: f64,
config: SpringConfig,
) {
let acceleration =
(-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass;
fn advance_value(current: &mut f64, target: f64, velocity: &mut f64, delta: f64, config: SpringConfig) {
let acceleration = (-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass;
*velocity += acceleration * delta;
let previous_error = *current - target;
*current += *velocity * delta;
@@ -641,17 +591,10 @@ 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);
}
}
@@ -678,9 +621,7 @@ 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);
@@ -690,11 +631,7 @@ 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!(
@@ -721,9 +658,7 @@ 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);
}
}
@@ -734,11 +669,7 @@ 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);
}
@@ -782,24 +713,15 @@ 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.presentation_size_request(Duration::from_millis(64)),
None
);
assert_eq!(
geometry.set_presentation_mode(
end,
PresentationMode::Normal,
Duration::from_millis(64)
),
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);
}
@@ -844,13 +766,8 @@ 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);
}
@@ -1004,11 +921,7 @@ 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]
@@ -1067,10 +980,7 @@ 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()));
+36 -119
View File
@@ -1,7 +1,9 @@
use std::fmt;
use std::path::PathBuf;
use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use std::{fmt, path::PathBuf};
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
@@ -36,14 +38,9 @@ pub fn default_wallpaper() -> &'static str {
.ok()
.and_then(|path| path.parent().map(|dir| dir.join("wallpapers/ferese.png")))
.filter(|path| path.is_file())
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png")
});
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png"));
path.canonicalize()
.unwrap_or(path)
.to_string_lossy()
.into_owned()
path.canonicalize().unwrap_or(path).to_string_lossy().into_owned()
})
}
@@ -65,10 +62,8 @@ fn field(name: &str, parent: &str) -> String {
fn is_records(key: &str, parent: &str) -> bool {
matches!(
(parent, key),
(
"",
"bindings" | "window_rules" | "output_profiles" | "autostart"
) | ("output_profiles", "outputs")
("", "bindings" | "window_rules" | "output_profiles" | "autostart")
| ("output_profiles", "outputs")
| ("desktop_widgets", "notes")
)
}
@@ -77,12 +72,7 @@ fn is_array(key: &str, parent: &str) -> bool {
parent == "commands"
|| matches!(
key,
"command"
| "position"
| "width_presets"
| "xkb_options"
| "settings_command"
| "outputs"
"command" | "position" | "width_presets" | "xkb_options" | "settings_command" | "outputs"
)
}
@@ -91,9 +81,7 @@ 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) {
@@ -124,9 +112,7 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
let key = field(node.name().value(), parent);
let value = 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)?;
@@ -138,9 +124,7 @@ 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
@@ -156,11 +140,7 @@ 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())?)?;
}
@@ -195,11 +175,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
node.name()
)));
}
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())?)?;
}
@@ -224,9 +200,7 @@ 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 })
@@ -267,10 +241,7 @@ 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()));
@@ -278,18 +249,12 @@ 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;
@@ -302,19 +267,14 @@ 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()))?;
@@ -346,11 +306,7 @@ 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())
@@ -391,11 +347,7 @@ 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;
}
@@ -403,11 +355,7 @@ 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);
}
@@ -500,9 +448,7 @@ 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
@@ -613,15 +559,9 @@ 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)?;
}
@@ -643,10 +583,7 @@ 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 {
@@ -677,10 +614,7 @@ desktop-widgets {
}
"#;
let mut doc = Document::parse(source).unwrap();
assert_eq!(
doc.get("commands.my_command").unwrap(),
&serde_json::json!(["program"])
);
assert_eq!(doc.get("commands.my_command").unwrap(), &serde_json::json!(["program"]));
assert_eq!(
doc.get("desktop_widgets.clock.outputs").unwrap(),
&serde_json::json!(["DP-1"])
@@ -691,8 +625,7 @@ desktop-widgets {
);
doc.set("commands.new_command", serde_json::json!(["two", "words"]))
.unwrap();
doc.set("desktop_widgets.clock.outputs", serde_json::json!([]))
.unwrap();
doc.set("desktop_widgets.clock.outputs", serde_json::json!([])).unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.new_command").unwrap(),
@@ -706,8 +639,7 @@ desktop-widgets {
#[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!(
@@ -750,15 +682,11 @@ 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]
@@ -786,28 +714,17 @@ desktop-widgets {
}
"#;
let mut doc = Document::parse(source).unwrap();
doc.set("desktop_widgets.notes.0.text", "one\ntwo".into())
.unwrap();
doc.set("desktop_widgets.notes.0.text", "one\ntwo".into()).unwrap();
assert!(doc.to_string().contains("// personal comment"));
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.screenshot-full").unwrap(),
&serde_json::json!(["ferese-screenshot", "--full"])
);
assert_eq!(
reparsed.get("desktop_widgets.notes.0.text").unwrap(),
"one\ntwo"
);
assert_eq!(reparsed.get("desktop_widgets.notes.0.text").unwrap(), "one\ntwo");
doc.add("desktop_widgets.notes", vec![("id".into(), "b".into())])
.unwrap();
assert_eq!(
doc.get("desktop_widgets.notes")
.unwrap()
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(doc.get("desktop_widgets.notes").unwrap().as_array().unwrap().len(), 2);
doc.remove("desktop_widgets.notes", 0).unwrap();
assert_eq!(doc.get("desktop_widgets.notes.0.id").unwrap(), "b");
}
+14 -29
View File
@@ -81,9 +81,7 @@ impl Default for StickyNote {
impl StickyNote {
pub fn on_output(&self, name: Option<&str>) -> bool {
self.enabled
&& (self.outputs.is_empty()
|| name.is_some_and(|name| self.outputs.iter().any(|s| s == name)))
self.enabled && (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|s| s == name)))
}
/// Text/style changes repaint in place, never remap the desktop card.
pub fn same_surface(&self, other: &Self) -> bool {
@@ -99,12 +97,7 @@ impl StickyNote {
}
pub fn validate(&self) -> Result<(), String> {
let invalid = |field: &str| {
Err(format!(
"desktop_widgets.notes[{}].{field} is invalid",
self.id
))
};
let invalid = |field: &str| Err(format!("desktop_widgets.notes[{}].{field} is invalid", self.id));
if self.id.is_empty()
|| self.id.len() > 64
|| !self
@@ -138,9 +131,9 @@ impl StickyNote {
for color in [&self.color, &self.background].into_iter().flatten() {
if !color.is_empty()
&& color != "theme"
&& !color.strip_prefix('#').is_some_and(|hex| {
matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())
})
&& !color
.strip_prefix('#')
.is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()))
{
return invalid("color/background");
}
@@ -253,8 +246,7 @@ impl Default for Clock {
impl Clock {
pub fn on_output(&self, name: Option<&str>) -> bool {
self.enabled
&& (self.outputs.is_empty()
|| name.is_some_and(|name| self.outputs.iter().any(|output| output == name)))
&& (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|output| output == name)))
}
pub fn labels(&self, now: &jiff::Zoned) -> Result<(String, String), String> {
@@ -264,10 +256,8 @@ impl Clock {
}
_ => now.clone(),
};
let time =
jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?;
let date =
jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?;
let time = jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?;
let date = jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?;
Ok(if self.lowercase {
(time.to_lowercase(), date.to_lowercase())
} else {
@@ -295,16 +285,14 @@ impl Clock {
return invalid(name);
}
}
for color in [&self.color, &self.date_color, &self.background]
.into_iter()
.flatten()
{
for color in [&self.color, &self.date_color, &self.background].into_iter().flatten() {
if color.is_empty() {
continue;
}
if !color.strip_prefix('#').is_some_and(|hex| {
matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())
}) {
if !color
.strip_prefix('#')
.is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()))
{
return invalid("color");
}
}
@@ -414,9 +402,6 @@ mod tests {
date_format: "%-d %b".into(),
..Clock::default()
};
assert_eq!(
clock.labels(&now).unwrap(),
("10:05 pm".into(), "26 sep".into())
);
assert_eq!(clock.labels(&now).unwrap(), ("10:05 pm".into(), "26 sep".into()));
}
}
+2 -2
View File
@@ -5,6 +5,6 @@ mod workspace;
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout,
WorkspaceSet, workspace_order_key,
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
workspace_order_key,
};
+45 -158
View File
@@ -1,8 +1,6 @@
use std::{
collections::{HashMap, HashSet},
error::Error,
fmt,
};
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt;
use crate::WorkspaceId;
@@ -19,12 +17,7 @@ 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) {
@@ -39,10 +32,7 @@ impl OutputGeometry {
pub enum OutputError {
AlreadyConnected(OutputId),
UnknownOutput(OutputId),
WorkspaceAlreadyAssigned {
workspace: WorkspaceId,
output: OutputId,
},
WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId },
}
impl fmt::Display for OutputError {
@@ -51,10 +41,7 @@ impl fmt::Display for OutputError {
Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"),
Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"),
Self::WorkspaceAlreadyAssigned { workspace, output } => {
write!(
formatter,
"workspace {workspace:?} is assigned to {output:?}"
)
write!(formatter, "workspace {workspace:?} is assigned to {output:?}")
}
}
}
@@ -111,11 +98,7 @@ 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)
@@ -125,9 +108,7 @@ impl OutputWorkspaceMap {
}
self.revisions.remove(&workspace);
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;
@@ -165,8 +146,7 @@ impl OutputWorkspaceMap {
let mut reclaimed = Vec::new();
if let Some(record) = &record {
for evacuated in &record.workspaces {
let assignment_matches =
self.assignments.get(&evacuated.workspace).copied() == evacuated.target;
let assignment_matches = self.assignments.get(&evacuated.workspace).copied() == evacuated.target;
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
if assignment_matches && revision_matches {
@@ -193,12 +173,7 @@ 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 active = match active {
Some(active) => active,
None => {
@@ -229,10 +204,7 @@ 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.migration_target(output, removed.geometry);
let removed_had_focus = self.focused == Some(output);
let mut evacuated = removed.workspaces.into_iter().collect::<Vec<_>>();
@@ -309,10 +281,7 @@ 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.active = workspace;
self.focused = Some(output);
@@ -324,11 +293,7 @@ impl OutputWorkspaceMap {
Ok(WorkspaceSwitch::Activated(output))
}
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));
}
@@ -348,11 +313,7 @@ impl OutputWorkspaceMap {
Ok(output)
}
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)
@@ -382,10 +343,7 @@ 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;
}
@@ -423,9 +381,7 @@ 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());
@@ -462,47 +418,28 @@ 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))));
@@ -513,12 +450,8 @@ 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();
@@ -526,29 +459,20 @@ 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)));
@@ -557,37 +481,24 @@ 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)
@@ -596,14 +507,8 @@ 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();
@@ -615,14 +520,8 @@ 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)));
}
@@ -631,12 +530,8 @@ 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)),
@@ -648,18 +543,10 @@ 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)));
}
}
+91 -335
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, PartialEq)]
@@ -44,11 +44,7 @@ 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);
@@ -161,17 +157,11 @@ impl WorkspaceLayout {
constraints,
focused.filter(|window| layout.contains(*window)),
),
Self::Tree(layout) => {
layout.geometry_with_constraints(bounds, gaps, constraints, focused)
}
Self::Tree(layout) => layout.geometry_with_constraints(bounds, gaps, constraints, focused),
}
}
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),
@@ -190,24 +180,14 @@ 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),
@@ -229,11 +209,7 @@ 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),
@@ -280,11 +256,7 @@ 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);
}
@@ -444,12 +416,7 @@ 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;
@@ -470,11 +437,7 @@ impl WorkspaceSet {
let workspace = Workspace {
id: active,
name: "1".to_owned(),
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,
@@ -520,12 +483,8 @@ 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))
}
@@ -543,8 +502,7 @@ impl WorkspaceSet {
pub fn ordered(&self) -> Vec<&Workspace> {
let mut workspaces = self.iter().collect::<Vec<_>>();
workspaces
.sort_unstable_by_key(|workspace| workspace_order_key(&workspace.name, workspace.id));
workspaces.sort_unstable_by_key(|workspace| workspace_order_key(&workspace.name, workspace.id));
workspaces
}
@@ -552,20 +510,12 @@ impl WorkspaceSet {
let mut empty = self
.workspaces
.values()
.filter(|workspace| {
workspace.layout.window_ids().next().is_none() && workspace.floating.is_empty()
})
.filter(|workspace| workspace.layout.window_ids().next().is_none() && workspace.floating.is_empty())
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
empty.sort_by_key(|id| id.0);
let visible_empty = empty
.iter()
.any(|id| *id == self.active || protected.contains(id));
let spare = if visible_empty {
None
} else {
empty.first().copied()
};
let visible_empty = empty.iter().any(|id| *id == self.active || protected.contains(id));
let spare = if visible_empty { None } else { empty.first().copied() };
let mut removed = Vec::new();
for id in empty {
if id == self.active || protected.contains(&id) || Some(id) == spare {
@@ -588,32 +538,20 @@ 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 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)?;
debug_assert!(self.validate().is_ok());
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)
@@ -639,35 +577,23 @@ 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);
@@ -676,11 +602,7 @@ 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))?;
@@ -721,11 +643,7 @@ 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)
{
@@ -734,10 +652,7 @@ 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)
}
@@ -804,12 +719,7 @@ 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));
}
@@ -1004,11 +914,7 @@ 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))?;
@@ -1038,18 +944,14 @@ impl WorkspaceSet {
return Err(WorkspaceError::InvalidState("active workspace is missing"));
}
if self.names.len() != self.workspaces.len() {
return Err(WorkspaceError::InvalidState(
"workspace name index is inconsistent",
));
return Err(WorkspaceError::InvalidState("workspace name index is inconsistent"));
}
let mut seen_windows = HashSet::new();
for (id, workspace) in &self.workspaces {
if workspace.id != *id || self.names.get(&workspace.name) != Some(id) {
return Err(WorkspaceError::InvalidState(
"workspace index is inconsistent",
));
return Err(WorkspaceError::InvalidState("workspace index is inconsistent"));
}
workspace.layout.validate()?;
@@ -1059,9 +961,7 @@ 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"));
}
}
@@ -1071,10 +971,7 @@ 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",
@@ -1086,9 +983,7 @@ 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
@@ -1101,12 +996,8 @@ 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(())
@@ -1114,13 +1005,10 @@ 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> {
@@ -1151,10 +1039,7 @@ mod tests {
let original_first = set.active_id();
let second = set.ensure_numeric(2).unwrap();
set.activate(second).unwrap();
assert_eq!(
set.prune_empty(&std::collections::HashSet::new()),
vec![original_first]
);
assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![original_first]);
let tenth = set.ensure_numeric(10).unwrap();
let first = set.ensure_numeric(1).unwrap();
@@ -1188,19 +1073,13 @@ mod tests {
.unwrap();
let spare = set.ensure_numeric(3).unwrap();
let excess = set.ensure_numeric(4).unwrap();
assert_eq!(
set.prune_empty(&std::collections::HashSet::new()),
vec![excess]
);
assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![excess]);
assert!(set.workspace(first).is_some());
assert!(set.workspace(floating).is_some());
assert!(set.workspace(spare).is_some());
set.activate(spare).unwrap();
set.remove_window(super::WindowId(1)).unwrap();
assert_eq!(
set.prune_empty(&std::collections::HashSet::new()),
vec![first]
);
assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![first]);
assert!(set.validate().is_ok());
assert_ne!(set.ensure_numeric(1).unwrap(), first);
}
@@ -1222,9 +1101,7 @@ 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!()
@@ -1263,30 +1140,18 @@ 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_numeric(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::*;
@@ -1304,14 +1169,9 @@ 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
@@ -1323,10 +1183,7 @@ 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_numeric(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
}
@@ -1334,14 +1191,10 @@ 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_numeric(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_numeric(1).unwrap(), Some(WindowId(1)));
assert_eq!(workspaces.switch_to_numeric(2).unwrap(), Some(WindowId(2)));
@@ -1351,12 +1204,8 @@ 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_numeric(WindowId(2), 2, Axis::Horizontal, 0.5)
@@ -1364,32 +1213,16 @@ 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)));
@@ -1400,9 +1233,7 @@ 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();
@@ -1414,9 +1245,7 @@ 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_numeric(WindowId(1), 2, Axis::Horizontal, 0.5)
.unwrap();
@@ -1433,9 +1262,7 @@ 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!(
@@ -1461,21 +1288,14 @@ 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))
@@ -1497,15 +1317,9 @@ 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 {
@@ -1555,20 +1369,13 @@ 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
.geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(4)),
)
.geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(4)))
.unwrap();
let scroll = workspaces.active().layout.viewport_x();
workspaces
@@ -1582,12 +1389,7 @@ mod tests {
let after = workspaces
.active_mut()
.layout
.geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(5)),
)
.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());
@@ -1598,9 +1400,7 @@ 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_numeric(2).unwrap();
workspaces
@@ -1612,10 +1412,7 @@ 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))
@@ -1626,9 +1423,7 @@ 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());
@@ -1646,13 +1441,9 @@ 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)));
@@ -1664,17 +1455,10 @@ 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)));
@@ -1688,14 +1472,10 @@ 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_numeric(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_numeric(WindowId(2), 1, Axis::Horizontal, 0.5)
.unwrap();
@@ -1704,9 +1484,7 @@ 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());
}
@@ -1714,12 +1492,8 @@ 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)));
@@ -1740,26 +1514,16 @@ 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)])
);
@@ -1777,19 +1541,14 @@ 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);
@@ -1817,9 +1576,7 @@ 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() => {
@@ -1866,8 +1623,7 @@ 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();
}
}
+3 -6
View File
@@ -1,6 +1,6 @@
use std::error::Error;
use std::fmt;
use std::io::{self, Read, Write};
use std::io::{Read, Write};
use std::{fmt, io};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -69,10 +69,7 @@ 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}"),
}
}
+56 -202
View File
@@ -54,12 +54,7 @@ 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) {
@@ -365,11 +360,7 @@ 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);
@@ -434,11 +425,7 @@ 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);
};
@@ -516,12 +503,7 @@ 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);
};
@@ -545,12 +527,7 @@ 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)
@@ -651,9 +628,7 @@ 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"))
@@ -662,12 +637,7 @@ 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,
@@ -684,12 +654,7 @@ 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")),
@@ -786,21 +751,11 @@ 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
@@ -826,10 +781,7 @@ 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)?;
@@ -860,9 +812,7 @@ 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,
}
}
@@ -887,15 +837,9 @@ 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"));
}
@@ -931,11 +875,7 @@ 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 {
@@ -1029,12 +969,7 @@ 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
}
@@ -1043,24 +978,14 @@ 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)
}
@@ -1074,43 +999,32 @@ 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();
@@ -1125,8 +1039,7 @@ 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),
@@ -1147,28 +1060,20 @@ 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();
@@ -1186,8 +1091,7 @@ 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
@@ -1206,8 +1110,7 @@ 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())
@@ -1219,8 +1122,7 @@ 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)
@@ -1233,13 +1135,11 @@ 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
);
}
@@ -1247,16 +1147,12 @@ 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);
@@ -1267,16 +1163,11 @@ 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()
);
}
@@ -1284,18 +1175,14 @@ 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);
@@ -1307,23 +1194,15 @@ 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);
@@ -1333,26 +1212,16 @@ 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();
@@ -1366,8 +1235,7 @@ 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();
@@ -1382,8 +1250,7 @@ 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();
@@ -1393,10 +1260,7 @@ 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());
}
@@ -1409,9 +1273,7 @@ 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 {
@@ -1425,8 +1287,7 @@ 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() => {
@@ -1453,8 +1314,7 @@ 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();
}
_ => {}
}
@@ -1466,8 +1326,7 @@ 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([
@@ -1507,8 +1366,7 @@ 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([
@@ -1550,8 +1408,7 @@ 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 {
@@ -1569,10 +1426,7 @@ 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);
}
+68 -240
View File
@@ -1,8 +1,8 @@
use std::collections::{HashMap, HashSet};
use crate::{
ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect,
SizeConstraints, WindowId, normalized_constraints, record_minimum_warnings,
ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect, SizeConstraints,
WindowId, normalized_constraints, record_minimum_warnings,
};
const DEFAULT_WIDTH: f64 = 0.5;
@@ -145,16 +145,10 @@ impl ScrollingLayout {
}
pub fn window_ids(&self) -> impl Iterator<Item = WindowId> + '_ {
self.columns
.iter()
.flat_map(|column| column.windows.iter().copied())
self.columns.iter().flat_map(|column| column.windows.iter().copied())
}
pub fn insert(
&mut self,
window: WindowId,
focused: Option<WindowId>,
) -> Result<(), LayoutError> {
pub fn insert(&mut self, window: WindowId, focused: Option<WindowId>) -> Result<(), LayoutError> {
if self.contains(window) {
return Err(LayoutError::DuplicateWindow(window));
}
@@ -163,8 +157,7 @@ impl ScrollingLayout {
.and_then(|focused| self.window_location(focused))
.map(|(column, _)| column + 1)
.unwrap_or(self.columns.len());
self.columns
.insert(index, Column::new(window, self.default_width));
self.columns.insert(index, Column::new(window, self.default_width));
self.active_column = Some(index);
self.reveal_pending = (self.columns.len() > 1).then_some(window);
@@ -173,12 +166,9 @@ impl ScrollingLayout {
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column_index, window_index) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column_index, window_index) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let column = &mut self.columns[column_index];
let removed_active_column =
self.active_column == Some(column_index) && column.windows.len() == 1;
let removed_active_column = self.active_column == Some(column_index) && column.windows.len() == 1;
column.windows.remove(window_index);
column.heights.remove(window_index);
@@ -206,9 +196,7 @@ impl ScrollingLayout {
}
pub fn focus(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column, index) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column, index) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let changed_column = self.active_column != Some(column);
self.active_column = Some(column);
self.columns[column].active = index;
@@ -221,9 +209,7 @@ impl ScrollingLayout {
/// Hover changes the active window without requesting viewport movement.
/// Keep any earlier explicit reveal attached to its original window.
pub fn focus_without_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column, index) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column, index) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
self.active_column = Some(column);
self.columns[column].active = index;
Ok(())
@@ -237,11 +223,7 @@ impl ScrollingLayout {
Ok(())
}
pub fn slide_focus_from(
&mut self,
previous: WindowId,
focused: WindowId,
) -> Result<(), LayoutError> {
pub fn slide_focus_from(&mut self, previous: WindowId, focused: WindowId) -> Result<(), LayoutError> {
self.window_location(previous)
.ok_or(LayoutError::UnknownWindow(previous))?;
self.focus_without_reveal(focused)?;
@@ -250,16 +232,11 @@ impl ScrollingLayout {
Ok(())
}
pub fn move_into_column(
&mut self,
window: WindowId,
target: WindowId,
) -> Result<(), LayoutError> {
pub fn move_into_column(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
if window == target {
return Ok(());
}
self.window_location(target)
.ok_or(LayoutError::UnknownWindow(target))?;
self.window_location(target).ok_or(LayoutError::UnknownWindow(target))?;
self.remove(window)?;
let (target_column, target_index) = self
.window_location(target)
@@ -278,9 +255,7 @@ impl ScrollingLayout {
}
pub fn extract_to_column(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (source, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
if self.columns[source].windows.len() == 1 {
self.active_column = Some(source);
return Ok(());
@@ -288,8 +263,7 @@ impl ScrollingLayout {
self.remove(window)?;
let insertion = (source + 1).min(self.columns.len());
self.columns
.insert(insertion, Column::new(window, self.default_width));
self.columns.insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion);
self.reveal_pending = self.active_window();
@@ -297,32 +271,16 @@ impl ScrollingLayout {
Ok(())
}
pub fn set_column_width(
&mut self,
window: WindowId,
width: ColumnWidth,
) -> Result<(), LayoutError> {
let (column, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
pub fn set_column_width(&mut self, window: WindowId, width: ColumnWidth) -> Result<(), LayoutError> {
let (column, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
self.columns[column].width = normalized_width(width);
self.reveal_pending = self.active_window();
Ok(())
}
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, LayoutError> {
let (column, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let presets = presets
.iter()
.copied()
.map(normalized_width)
.collect::<Vec<_>>();
pub fn cycle_column_width(&mut self, window: WindowId, presets: &[ColumnWidth]) -> Result<bool, LayoutError> {
let (column, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let presets = presets.iter().copied().map(normalized_width).collect::<Vec<_>>();
if presets.is_empty() {
return Ok(false);
}
@@ -365,35 +323,22 @@ impl ScrollingLayout {
window: WindowId,
direction: Direction,
) -> Result<Option<WindowId>, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let neighbor = match direction {
Direction::Up => row
.checked_sub(1)
.map(|row| self.columns[column].windows[row]),
Direction::Up => row.checked_sub(1).map(|row| self.columns[column].windows[row]),
Direction::Down => self.columns[column].windows.get(row + 1).copied(),
Direction::Left => column.checked_sub(1).map(|column| {
let column = &self.columns[column];
column.windows[column.active]
}),
Direction::Right => self
.columns
.get(column + 1)
.map(|column| column.windows[column.active]),
Direction::Right => self.columns.get(column + 1).map(|column| column.windows[column.active]),
};
Ok(neighbor)
}
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
pub fn move_window(&mut self, window: WindowId, direction: Direction) -> Result<bool, LayoutError> {
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
match direction {
Direction::Up if row > 0 => {
@@ -407,9 +352,7 @@ impl ScrollingLayout {
self.columns[column].active = row + 1;
}
Direction::Left if column > 0 => self.move_horizontally(window, column - 1)?,
Direction::Right if column + 1 < self.columns.len() => {
self.move_horizontally(window, column + 1)?
}
Direction::Right if column + 1 < self.columns.len() => self.move_horizontally(window, column + 1)?,
_ => return Ok(false),
}
@@ -417,20 +360,9 @@ impl ScrollingLayout {
Ok(true)
}
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let amount = if amount.is_finite() {
amount.abs()
} else {
0.0
};
pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result<bool, LayoutError> {
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let amount = if amount.is_finite() { amount.abs() } else { 0.0 };
if amount == 0.0 {
return Ok(false);
}
@@ -441,18 +373,12 @@ impl ScrollingLayout {
ColumnWidth::Proportion(value) => value,
ColumnWidth::Fixed(_) | ColumnWidth::Full => DEFAULT_WIDTH,
};
let adjustment = if direction == Direction::Right {
amount
} else {
-amount
};
let adjustment = if direction == Direction::Right { amount } else { -amount };
let resized = (current + adjustment).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
self.columns[column].width = ColumnWidth::Proportion(resized);
resized != current
}
Direction::Up if row > 0 => {
resize_rows(&mut self.columns[column], row, row - 1, amount)
}
Direction::Up if row > 0 => resize_rows(&mut self.columns[column], row, row - 1, amount),
Direction::Down if row + 1 < self.columns[column].windows.len() => {
resize_rows(&mut self.columns[column], row, row + 1, amount)
}
@@ -496,9 +422,7 @@ impl ScrollingLayout {
for column in &self.columns {
let requested = match normalized_width(column.width) {
ColumnWidth::Proportion(proportion) => {
((viewport_width + inner) * proportion - inner).max(1.0)
}
ColumnWidth::Proportion(proportion) => ((viewport_width + inner) * proportion - inner).max(1.0),
ColumnWidth::Fixed(width) => width,
ColumnWidth::Full => viewport_width,
};
@@ -526,18 +450,14 @@ impl ScrollingLayout {
self.reveal_pending = None;
self.swipe_focus_pending = None;
} else if let Some((previous, focused)) = self.swipe_focus_pending.take() {
if let (Some((from, _)), Some((to, _))) = (
self.window_location(previous),
self.window_location(focused),
) {
if let (Some((from, _)), Some((to, _))) = (self.window_location(previous), self.window_location(focused)) {
let last_end = column_positions
.last()
.map(|(start, width)| start + width)
.unwrap_or(0.0);
let max_offset = (last_end - viewport_width).max(0.0);
self.viewport_x = (self.viewport_x + column_positions[to].0
- column_positions[from].0)
.clamp(0.0, max_offset);
self.viewport_x =
(self.viewport_x + column_positions[to].0 - column_positions[from].0).clamp(0.0, max_offset);
}
self.reveal_pending = None;
} else {
@@ -554,9 +474,7 @@ impl ScrollingLayout {
let mut result = LayoutResult::default();
for (column_index, column) in self.columns.iter().enumerate() {
let (column_x, column_width) = column_positions[column_index];
let available_height = (viewport_height
- inner * (column.windows.len().saturating_sub(1) as f64))
.max(1.0);
let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(1.0);
let mut y = bounds.y + outer;
for (window_index, window) in column.windows.iter().enumerate() {
@@ -615,12 +533,7 @@ impl ScrollingLayout {
}
}
fn paged_viewport(
&self,
positions: &[(f64, f64)],
viewport_width: f64,
active: Option<usize>,
) -> Option<f64> {
fn paged_viewport(&self, positions: &[(f64, f64)], viewport_width: f64, active: Option<usize>) -> Option<f64> {
if self.focus_strategy != ViewportFocusStrategy::Paged {
return None;
}
@@ -671,14 +584,8 @@ impl ScrollingLayout {
}
}
fn move_horizontally(
&mut self,
window: WindowId,
destination: usize,
) -> Result<(), LayoutError> {
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
fn move_horizontally(&mut self, window: WindowId, destination: usize) -> Result<(), LayoutError> {
let (source, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
if self.columns[source].windows.len() == 1 {
self.columns.swap(source, destination);
self.active_column = Some(destination);
@@ -686,15 +593,10 @@ impl ScrollingLayout {
return Ok(());
}
let insertion = if destination < source {
source
} else {
source + 1
};
let insertion = if destination < source { source } else { source + 1 };
self.remove(window)?;
let insertion = insertion.min(self.columns.len());
self.columns
.insert(insertion, Column::new(window, self.default_width));
self.columns.insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion);
self.reveal_pending = self.active_window();
Ok(())
@@ -711,31 +613,20 @@ impl ScrollingLayout {
fn validate_scalars(&self) -> Result<(), LayoutError> {
if !self.viewport_x.is_finite() {
return Err(LayoutError::InvalidTree(
"scrolling viewport offset is invalid",
));
return Err(LayoutError::InvalidTree("scrolling viewport offset is invalid"));
}
if normalized_width(self.default_width) != self.default_width {
return Err(LayoutError::InvalidTree(
"scrolling default column width is invalid",
));
return Err(LayoutError::InvalidTree("scrolling default column width is invalid"));
}
if self.columns.is_empty() {
return if self.active_column.is_none() {
Ok(())
} else {
Err(LayoutError::InvalidTree(
"empty scrolling layout has an active column",
))
Err(LayoutError::InvalidTree("empty scrolling layout has an active column"))
};
}
if self
.active_column
.is_none_or(|active| active >= self.columns.len())
{
return Err(LayoutError::InvalidTree(
"scrolling active column is invalid",
));
if self.active_column.is_none_or(|active| active >= self.columns.len()) {
return Err(LayoutError::InvalidTree("scrolling active column is invalid"));
}
Ok(())
}
@@ -746,24 +637,15 @@ impl ScrollingLayout {
let mut windows = HashSet::new();
for column in &self.columns {
if normalized_width(column.width) != column.width {
return Err(LayoutError::InvalidTree(
"scrolling column width is invalid",
));
return Err(LayoutError::InvalidTree("scrolling column width is invalid"));
}
if column.windows.is_empty() {
return Err(LayoutError::InvalidTree("scrolling column is empty"));
}
if column.active >= column.windows.len() || column.heights.len() != column.windows.len()
{
return Err(LayoutError::InvalidTree(
"scrolling column metadata is invalid",
));
if column.active >= column.windows.len() || column.heights.len() != column.windows.len() {
return Err(LayoutError::InvalidTree("scrolling column metadata is invalid"));
}
if !column
.heights
.iter()
.all(|height| height.is_finite() && *height > 0.0)
{
if !column.heights.iter().all(|height| height.is_finite() && *height > 0.0) {
return Err(LayoutError::InvalidTree("scrolling height is invalid"));
}
for window in &column.windows {
@@ -790,9 +672,7 @@ fn resize_rows(column: &mut Column, row: usize, neighbor: usize, amount: f64) ->
fn normalized_width(width: ColumnWidth) -> ColumnWidth {
match width {
ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => {
ColumnWidth::Proportion(value)
}
ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => ColumnWidth::Proportion(value),
ColumnWidth::Fixed(value) if value.is_finite() && value > 0.0 => ColumnWidth::Fixed(value),
ColumnWidth::Full => ColumnWidth::Full,
_ => ColumnWidth::default(),
@@ -800,11 +680,7 @@ fn normalized_width(width: ColumnWidth) -> ColumnWidth {
}
fn finite_nonnegative(value: f64) -> f64 {
if value.is_finite() {
value.max(0.0)
} else {
0.0
}
if value.is_finite() { value.max(0.0) } else { 0.0 }
}
fn apply_maximums(
@@ -866,12 +742,7 @@ mod tests {
layout.insert(window(2), Some(window(1))).unwrap();
assert_eq!(layout.default_width(), ColumnWidth::Full);
assert!(
layout
.columns()
.iter()
.all(|column| column.width == ColumnWidth::Full)
);
assert!(layout.columns().iter().all(|column| column.width == ColumnWidth::Full));
}
#[test]
@@ -920,9 +791,7 @@ mod tests {
fn focus_reveal_scrolls_only_as_far_as_needed() {
let mut layout = ScrollingLayout::default();
for id in 1..=4 {
layout
.insert(window(id), Some(window(id.saturating_sub(1))))
.unwrap();
layout.insert(window(id), Some(window(id.saturating_sub(1)))).unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let gaps = GapConfig {
@@ -961,14 +830,8 @@ mod tests {
assert_eq!(layout.viewport_x(), 0.0);
assert_eq!(first.geometry, second.geometry);
assert_eq!(
second.geometry[&window(1)],
Rect::new(10.0, 10.0, 485.0, 780.0)
);
assert_eq!(
second.geometry[&window(2)],
Rect::new(505.0, 10.0, 485.0, 780.0)
);
assert_eq!(second.geometry[&window(1)], Rect::new(10.0, 10.0, 485.0, 780.0));
assert_eq!(second.geometry[&window(2)], Rect::new(505.0, 10.0, 485.0, 780.0));
}
#[test]
@@ -1037,13 +900,9 @@ mod tests {
.unwrap();
assert_eq!(layout.viewport_x(), 0.0);
for (previous, focused, expected) in
[(1, 2, 495.0), (2, 3, 990.0), (3, 2, 495.0), (2, 1, 0.0)]
{
for (previous, focused, expected) in [(1, 2, 495.0), (2, 3, 990.0), (3, 2, 495.0), (2, 1, 0.0)] {
layout.focus_and_reveal(window(focused)).unwrap();
layout
.slide_focus_from(window(previous), window(focused))
.unwrap();
layout.slide_focus_from(window(previous), window(focused)).unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap();
@@ -1071,9 +930,7 @@ mod tests {
#[test]
fn paged_focus_preserves_explicit_center_until_focus_changes() {
let (mut layout, bounds, gaps) = paged_layout(4);
layout
.center_window(window(2), bounds, gaps, &HashMap::new())
.unwrap();
layout.center_window(window(2), bounds, gaps, &HashMap::new()).unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
@@ -1188,18 +1045,14 @@ mod tests {
};
for id in 1..=3 {
layout
.insert(window(id), (id > 1).then(|| window(id - 1)))
.unwrap();
layout.insert(window(id), (id > 1).then(|| window(id - 1))).unwrap();
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(id)))
.unwrap();
}
let before = layout.viewport_x();
layout
.resize_window(window(3), Direction::Right, 0.5)
.unwrap();
layout.resize_window(window(3), Direction::Right, 0.5).unwrap();
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3)))
.unwrap();
@@ -1345,18 +1198,10 @@ mod tests {
layout.insert(window(2), Some(window(1))).unwrap();
layout.move_into_column(window(2), window(1)).unwrap();
assert!(
layout
.resize_window(window(1), Direction::Right, 0.1)
.unwrap()
);
assert!(layout.resize_window(window(1), Direction::Right, 0.1).unwrap());
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.6));
assert!(
layout
.resize_window(window(1), Direction::Down, 0.1)
.unwrap()
);
assert!(layout.resize_window(window(1), Direction::Down, 0.1).unwrap());
assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]);
}
@@ -1371,10 +1216,7 @@ mod tests {
];
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(
layout.columns()[0].width,
ColumnWidth::Proportion(2.0 / 3.0)
);
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(2.0 / 3.0));
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(layout.columns()[0].width, ColumnWidth::Full);
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
@@ -1459,10 +1301,7 @@ mod tests {
let mut layout = ScrollingLayout::default();
for id in 1_u64..=4 {
layout
.insert(
window(id),
id.checked_sub(1).filter(|id| *id > 0).map(window),
)
.insert(window(id), id.checked_sub(1).filter(|id| *id > 0).map(window))
.unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
@@ -1475,11 +1314,7 @@ mod tests {
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
.unwrap();
assert!(
layout
.center_window(window(2), bounds, gaps, &HashMap::new())
.unwrap()
);
assert!(layout.center_window(window(2), bounds, gaps, &HashMap::new()).unwrap());
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
@@ -1518,9 +1353,7 @@ mod tests {
if first == second {
second = (second + 1) % windows.len();
}
layout
.move_into_column(windows[first], windows[second])
.unwrap();
layout.move_into_column(windows[first], windows[second]).unwrap();
}
3 if !windows.is_empty() => {
let index = random as usize % windows.len();
@@ -1534,9 +1367,7 @@ mod tests {
5 if !windows.is_empty() => {
let index = random as usize % windows.len();
let direction = random_direction(random.rotate_left(23));
layout
.resize_window(windows[index], direction, 0.05)
.unwrap();
layout.resize_window(windows[index], direction, 0.05).unwrap();
}
6 if !windows.is_empty() => {
let index = random as usize % windows.len();
@@ -1546,10 +1377,7 @@ mod tests {
}
layout.validate().unwrap();
assert_eq!(
layout.window_ids().collect::<HashSet<_>>().len(),
windows.len()
);
assert_eq!(layout.window_ids().collect::<HashSet<_>>().len(), windows.len());
}
}
+4 -12
View File
@@ -195,15 +195,11 @@ 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")]
@@ -216,15 +212,11 @@ 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");
}
}
}
+5 -10
View File
@@ -1,9 +1,8 @@
use cosmic::iced::{Background, Color};
use cosmic::theme;
use cosmic::widget::button;
use crate::{composite, foreground};
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
#[derive(Clone, Copy)]
pub(crate) enum State {
@@ -13,11 +12,7 @@ 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 {
+21 -54
View File
@@ -1,10 +1,7 @@
//! Date/time labels and a calendar grid; callers own navigation and popup behavior.
use cosmic::{
Element,
iced::{Alignment, Background, Border, Length},
theme,
widget::{column, container, row},
};
use cosmic::iced::{Alignment, Background, Border, Length};
use cosmic::widget::{column, container, row};
use cosmic::{Element, theme};
use jiff::Zoned;
pub fn format_bar_time(now: &Zoned) -> String {
@@ -41,10 +38,7 @@ pub fn stacked_digits(label: &str) -> Option<[String; 2]> {
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
|| !hour
.bytes()
.chain(minute.bytes())
.all(|digit| digit.is_ascii_digit())
|| !hour.bytes().chain(minute.bytes()).all(|digit| digit.is_ascii_digit())
{
return None;
}
@@ -104,13 +98,9 @@ pub fn grid<'a, M: Clone + 'a>(
let mut weekdays = row::with_capacity(7).spacing(3);
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(
crate::text(label, font)
.size(11)
.class(theme::Text::Color(muted)),
)
.width(30)
.center_x(30),
container(crate::text(label, font).size(11).class(theme::Text::Color(muted)))
.width(30)
.center_x(30),
);
}
grid = grid.push(weekdays);
@@ -118,26 +108,21 @@ pub fn grid<'a, M: Clone + 'a>(
let mut dates = row::with_capacity(7).spacing(3);
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(30)
.height(25)
.center_x(30)
.center_y(25)
.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 {
radius: palette.radius.min(12.).into(),
..Default::default()
@@ -155,14 +140,8 @@ pub fn grid<'a, M: Clone + 'a>(
mod tests {
#[test]
fn pixel_clock_stacks_only_hour_and_minute_formats() {
assert_eq!(
super::stacked_digits("2:45 pm"),
Some(["02".into(), "45".into()])
);
assert_eq!(
super::stacked_digits("14:45"),
Some(["14".into(), "45".into()])
);
assert_eq!(super::stacked_digits("2:45 pm"), Some(["02".into(), "45".into()]));
assert_eq!(super::stacked_digits("14:45"), Some(["14".into(), "45".into()]));
assert!(super::stacked_digits("14:45:30").is_none());
assert!(super::stacked_digits("Today 14:45").is_none());
assert!(super::stacked_digits("14:45 pm extra").is_none());
@@ -171,18 +150,9 @@ 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);
}
@@ -194,10 +164,7 @@ 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));
+11 -42
View File
@@ -76,27 +76,12 @@ 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,9 +110,10 @@ fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
#[cfg(test)]
mod tests {
use cosmic::iced::Background;
use super::*;
use crate::button::{State, accent_button_style};
use cosmic::iced::Background;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
@@ -139,10 +125,7 @@ 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]
@@ -170,12 +153,7 @@ 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));
@@ -223,20 +201,11 @@ 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")
+8 -24
View File
@@ -1,9 +1,8 @@
use cosmic::iced::{Background, Border, Color, Vector};
use cosmic::widget::{button, container};
use cosmic::{theme, widget};
use crate::{Palette, mix};
use cosmic::{
iced::{Background, Border, Color, Vector},
theme,
widget::{self, button, container},
};
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| surface_appearance(background, radius))
@@ -85,11 +84,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
icon_color: Some(on),
border_radius: p.radius.min(9.).into(),
border_width: if selected { 1. } else { 0. },
border_color: if selected {
p.accent
} else {
Color::TRANSPARENT
},
border_color: if selected { p.accent } else { Color::TRANSPARENT },
outline_width: 0.,
outline_color: Color::TRANSPARENT,
overlay: None,
@@ -136,11 +131,7 @@ 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 {
background: cosmic::iced::Color::from_rgba(
(surface.r + 1.) * 0.5,
@@ -190,9 +181,7 @@ 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
})),
@@ -211,12 +200,7 @@ 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 {
text_color: Some(foreground),
icon_color: Some(foreground),
+7 -13
View File
@@ -1,9 +1,7 @@
//! Canonical SVG assets, sizing and semantic tint rules for native Ferese UIs.
use cosmic::{
iced::{Color, ContentFit, Length},
theme,
widget::icon,
};
use cosmic::iced::{Color, ContentFit, Length};
use cosmic::theme;
use cosmic::widget::icon;
pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
@@ -18,8 +16,7 @@ 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");
@@ -67,10 +64,7 @@ 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)
}
pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon {
@@ -88,8 +82,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,
}))
}
+9 -17
View File
@@ -1,14 +1,10 @@
use std::sync::Arc;
use ferese_protocols::material::v1::client::{
ferese_material_manager_v1::FereseMaterialManagerV1,
ferese_surface_material_v1::FereseSurfaceMaterialV1,
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_surface},
};
use ferese_protocols::material::v1::client::ferese_material_manager_v1::FereseMaterialManagerV1;
use ferese_protocols::material::v1::client::ferese_surface_material_v1::FereseSurfaceMaterialV1;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
/// Keeps a compositor-owned dialog material attached for the window lifetime.
#[derive(Clone, Debug)]
@@ -37,9 +33,8 @@ 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(
@@ -48,8 +43,7 @@ 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();
@@ -63,9 +57,7 @@ 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());
}
+10 -31
View File
@@ -1,9 +1,7 @@
//! Shared menu layouts and paint; callers own actions and animation timing.
use cosmic::{
Element,
iced::{Alignment, Color, Length},
widget::{button, container, row as iced_row},
};
use cosmic::Element;
use cosmic::iced::{Alignment, Color, Length};
use cosmic::widget::{button, container, row as iced_row};
pub fn button<'a, M: Clone + 'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
@@ -20,12 +18,9 @@ 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>(
@@ -38,12 +33,7 @@ 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)
@@ -56,12 +46,7 @@ pub fn badge<'a, M: 'a>(
.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)
@@ -87,10 +72,7 @@ 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()
}
@@ -112,10 +94,7 @@ 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 },
palette.radius.min(8.),
));
container(knob)
+3 -9
View File
@@ -102,11 +102,7 @@ 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 {
@@ -179,10 +175,8 @@ mod tests {
("translucent", 2., 1.),
("translucent", -1., 0.),
] {
let document = Document::parse(&format!(
"theme {{ material {{ style {style}; opacity {opacity}; }} }}"
))
.unwrap();
let document =
Document::parse(&format!("theme {{ material {{ style {style}; opacity {opacity}; }} }}")).unwrap();
assert_eq!(material_opacity(Some(&document)), expected);
}
}
+2 -4
View File
@@ -1,7 +1,5 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
pub fn font(family: Option<&str>) -> cosmic::font::Font {
let Some(family) = family else {