Format Rust sources at 100 columns
This commit is contained in:
1 parent
4661f595f8
commit
610478f8c7
336 files changed
+23497
-7892
No files matched your search
@@ -25,7 +25,9 @@ pub(crate) enum Kind {
|
||||
pub(super) fn valid_answer(kind: &Kind, value: &str) -> bool {
|
||||
match kind {
|
||||
Kind::Pin => !value.is_empty() && value.chars().count() <= 16,
|
||||
Kind::Passkey => !value.is_empty() && value.len() <= 6 && value.bytes().all(|b| b.is_ascii_digit()),
|
||||
Kind::Passkey => {
|
||||
!value.is_empty() && value.len() <= 6 && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
Kind::Display(_) => false,
|
||||
_ => true,
|
||||
}
|
||||
@@ -107,19 +109,29 @@ impl Pairing {
|
||||
}
|
||||
|
||||
fn check(state: &Pending, device: &str, sender: Option<&str>) -> Result<(), Error> {
|
||||
if state.target.as_deref() != Some(device) || sender.is_none() || state.owner.as_deref() != sender {
|
||||
if state.target.as_deref() != Some(device)
|
||||
|| sender.is_none()
|
||||
|| state.owner.as_deref() != sender
|
||||
{
|
||||
Err(Error::Rejected("No matching pairing request".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ask(&self, device: OwnedObjectPath, kind: Kind, sender: Option<&str>) -> Result<Zeroizing<String>, Error> {
|
||||
async fn ask(
|
||||
&self,
|
||||
device: OwnedObjectPath,
|
||||
kind: Kind,
|
||||
sender: Option<&str>,
|
||||
) -> Result<Zeroizing<String>, Error> {
|
||||
let (id, rx) = {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
Self::check(&state, device.as_str(), sender)?;
|
||||
if state.reply.is_some() {
|
||||
return Err(Error::Rejected("A pairing question is already pending".into()));
|
||||
return Err(Error::Rejected(
|
||||
"A pairing question is already pending".into(),
|
||||
));
|
||||
}
|
||||
state.serial = state.serial.wrapping_add(1);
|
||||
let id = state.serial;
|
||||
@@ -141,7 +153,12 @@ impl Pairing {
|
||||
.ok_or_else(|| Error::Canceled("Pairing canceled or timed out".into()))
|
||||
}
|
||||
|
||||
fn display(&self, device: OwnedObjectPath, text: String, sender: Option<&str>) -> Result<(), Error> {
|
||||
fn display(
|
||||
&self,
|
||||
device: OwnedObjectPath,
|
||||
text: String,
|
||||
sender: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
Self::check(&state, device.as_str(), sender)?;
|
||||
state.serial = state.serial.wrapping_add(1);
|
||||
@@ -171,14 +188,20 @@ impl Pairing {
|
||||
device: OwnedObjectPath,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<String, Error> {
|
||||
let answer = self.ask(device, Kind::Pin, header.sender().map(|s| s.as_str())).await?;
|
||||
let answer = self
|
||||
.ask(device, Kind::Pin, header.sender().map(|s| s.as_str()))
|
||||
.await?;
|
||||
if !valid_answer(&Kind::Pin, &answer) {
|
||||
return Err(Error::Rejected("PIN must have 1–16 characters".into()));
|
||||
}
|
||||
Ok(answer.to_string())
|
||||
}
|
||||
|
||||
async fn request_passkey(&self, device: OwnedObjectPath, #[zbus(header)] header: Header<'_>) -> Result<u32, Error> {
|
||||
async fn request_passkey(
|
||||
&self,
|
||||
device: OwnedObjectPath,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<u32, Error> {
|
||||
let answer = self
|
||||
.ask(device, Kind::Passkey, header.sender().map(|s| s.as_str()))
|
||||
.await?;
|
||||
@@ -198,9 +221,13 @@ impl Pairing {
|
||||
passkey: u32,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<(), Error> {
|
||||
self.ask(device, Kind::Confirm(passkey), header.sender().map(|s| s.as_str()))
|
||||
.await
|
||||
.map(|_| ())
|
||||
self.ask(
|
||||
device,
|
||||
Kind::Confirm(passkey),
|
||||
header.sender().map(|s| s.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn request_authorization(
|
||||
@@ -219,9 +246,13 @@ impl Pairing {
|
||||
uuid: String,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<(), Error> {
|
||||
self.ask(device, Kind::Service(uuid), header.sender().map(|s| s.as_str()))
|
||||
.await
|
||||
.map(|_| ())
|
||||
self.ask(
|
||||
device,
|
||||
Kind::Service(uuid),
|
||||
header.sender().map(|s| s.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn display_pin_code(
|
||||
@@ -276,7 +307,9 @@ impl Client {
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut registered = self.registered.lock().await;
|
||||
if registered.as_ref() == Some(&owner) && self.agent.0.lock().unwrap().owner.as_ref() == Some(&owner) {
|
||||
if registered.as_ref() == Some(&owner)
|
||||
&& self.agent.0.lock().unwrap().owner.as_ref() == Some(&owner)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.agent.0.lock().unwrap().owner = Some(owner.clone());
|
||||
@@ -286,11 +319,16 @@ impl Client {
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let path = OwnedObjectPath::try_from(PATH).unwrap();
|
||||
proxy(&self.connection, "org.bluez", "/org/bluez", "org.bluez.AgentManager1")
|
||||
.await?
|
||||
.call::<_, _, ()>("RegisterAgent", &(path, "KeyboardDisplay"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
proxy(
|
||||
&self.connection,
|
||||
"org.bluez",
|
||||
"/org/bluez",
|
||||
"org.bluez.AgentManager1",
|
||||
)
|
||||
.await?
|
||||
.call::<_, _, ()>("RegisterAgent", &(path, "KeyboardDisplay"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
*registered = Some(owner);
|
||||
Ok(())
|
||||
}
|
||||
@@ -338,11 +376,22 @@ mod tests {
|
||||
pairing.0.lock().unwrap().owner = Some(":1.2".into());
|
||||
let generation = pairing.begin("/device/1").unwrap();
|
||||
let path = OwnedObjectPath::try_from("/device/2").unwrap();
|
||||
assert!(pairing.ask(path, Kind::Authorize, Some(":1.2")).await.is_err());
|
||||
assert!(
|
||||
pairing
|
||||
.ask(path, Kind::Authorize, Some(":1.2"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let path = OwnedObjectPath::try_from("/device/1").unwrap();
|
||||
assert!(pairing.ask(path.clone(), Kind::Authorize, Some(":1.9")).await.is_err());
|
||||
assert!(
|
||||
pairing
|
||||
.ask(path.clone(), Kind::Authorize, Some(":1.9"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let other = pairing.clone();
|
||||
let task = tokio::spawn(async move { other.ask(path, Kind::Confirm(123456), Some(":1.2")).await });
|
||||
let task =
|
||||
tokio::spawn(async move { other.ask(path, Kind::Confirm(123456), Some(":1.2")).await });
|
||||
tokio::task::yield_now().await;
|
||||
let id = pairing.prompt().unwrap().id;
|
||||
pairing.reply(id.wrapping_sub(1), Some(Zeroizing::new(String::new())));
|
||||
@@ -361,7 +410,11 @@ mod tests {
|
||||
let other = pairing.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
other
|
||||
.ask(OwnedObjectPath::try_from("/device/1").unwrap(), Kind::Pin, Some(":1.2"))
|
||||
.ask(
|
||||
OwnedObjectPath::try_from("/device/1").unwrap(),
|
||||
Kind::Pin,
|
||||
Some(":1.2"),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
@@ -370,6 +423,9 @@ mod tests {
|
||||
assert!(task.await.unwrap().is_err());
|
||||
pairing.begin("/device/2").unwrap();
|
||||
pairing.finish(old);
|
||||
assert_eq!(pairing.0.lock().unwrap().target.as_deref(), Some("/device/2"));
|
||||
assert_eq!(
|
||||
pairing.0.lock().unwrap().target.as_deref(),
|
||||
Some("/device/2")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,12 @@ pub(crate) struct Bluetooth {
|
||||
}
|
||||
|
||||
pub(crate) async fn read(connection: &Connection) -> Result<Bluetooth, String> {
|
||||
let objects: ManagedObjects = proxy(connection, BLUEZ, "/", "org.freedesktop.DBus.ObjectManager")
|
||||
.await?
|
||||
.call("GetManagedObjects", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let objects: ManagedObjects =
|
||||
proxy(connection, BLUEZ, "/", "org.freedesktop.DBus.ObjectManager")
|
||||
.await?
|
||||
.call("GetManagedObjects", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(snapshot(&objects))
|
||||
}
|
||||
|
||||
@@ -85,7 +86,11 @@ fn snapshot(objects: &ManagedObjects) -> Bluetooth {
|
||||
result.devices.push(Device {
|
||||
path: path.to_string(),
|
||||
adapter: adapter.to_string(),
|
||||
name: if alias.is_empty() { address.clone() } else { alias },
|
||||
name: if alias.is_empty() {
|
||||
address.clone()
|
||||
} else {
|
||||
alias
|
||||
},
|
||||
address,
|
||||
icon: string(p, "Icon"),
|
||||
connected: boolean(p, "Connected"),
|
||||
@@ -116,7 +121,11 @@ pub(crate) async fn power(connection: &Connection, path: &str, on: bool) -> Resu
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn adapter_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
|
||||
pub(crate) async fn adapter_call(
|
||||
connection: &Connection,
|
||||
path: &str,
|
||||
method: &str,
|
||||
) -> Result<(), String> {
|
||||
proxy(connection, BLUEZ, path, ADAPTER)
|
||||
.await?
|
||||
.call::<_, _, ()>(method, &())
|
||||
@@ -124,7 +133,11 @@ pub(crate) async fn adapter_call(connection: &Connection, path: &str, method: &s
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn device_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
|
||||
pub(crate) async fn device_call(
|
||||
connection: &Connection,
|
||||
path: &str,
|
||||
method: &str,
|
||||
) -> Result<(), String> {
|
||||
proxy(connection, BLUEZ, path, DEVICE)
|
||||
.await?
|
||||
.call::<_, _, ()>(method, &())
|
||||
@@ -140,7 +153,11 @@ pub(crate) async fn trust(connection: &Connection, path: &str, on: bool) -> Resu
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn forget(connection: &Connection, adapter: &str, path: &str) -> Result<(), String> {
|
||||
pub(crate) async fn forget(
|
||||
connection: &Connection,
|
||||
adapter: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = OwnedObjectPath::try_from(path).map_err(|e| e.to_string())?;
|
||||
proxy(connection, BLUEZ, adapter, ADAPTER)
|
||||
.await?
|
||||
|
||||
@@ -35,7 +35,11 @@ impl App {
|
||||
}
|
||||
body = body.push(choices);
|
||||
}
|
||||
let Some(adapter) = bluetooth.adapters.iter().find(|a| Some(&a.path) == s.adapter.as_ref()) else {
|
||||
let Some(adapter) = bluetooth
|
||||
.adapters
|
||||
.iter()
|
||||
.find(|a| Some(&a.path) == s.adapter.as_ref())
|
||||
else {
|
||||
return body
|
||||
.push(self.connection_note("No Bluetooth adapter is available."))
|
||||
.into();
|
||||
@@ -71,7 +75,10 @@ impl App {
|
||||
if !adapter.powered {
|
||||
return body.into();
|
||||
}
|
||||
let scanning = s.client.as_ref().is_some_and(|c| c.discovering(&adapter.path));
|
||||
let scanning = s
|
||||
.client
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.discovering(&adapter.path));
|
||||
body = body.push(
|
||||
row([])
|
||||
.align_y(Alignment::Center)
|
||||
@@ -87,7 +94,11 @@ impl App {
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(self.connection_button(
|
||||
if scanning { "Stop scan" } else { "Find devices" },
|
||||
if scanning {
|
||||
"Stop scan"
|
||||
} else {
|
||||
"Find devices"
|
||||
},
|
||||
Input::Run(if scanning {
|
||||
Command::BluetoothStop
|
||||
} else {
|
||||
@@ -102,9 +113,20 @@ impl App {
|
||||
} else {
|
||||
"Choose Find devices to discover nearby devices in pairing mode."
|
||||
}));
|
||||
let devices: Vec<_> = bluetooth.devices.iter().filter(|d| d.adapter == adapter.path).collect();
|
||||
let devices: Vec<_> = bluetooth
|
||||
.devices
|
||||
.iter()
|
||||
.filter(|d| d.adapter == adapter.path)
|
||||
.collect();
|
||||
for paired in [true, false] {
|
||||
body = body.push(self.label(if paired { "Paired devices" } else { "Nearby devices" }, 14.));
|
||||
body = body.push(self.label(
|
||||
if paired {
|
||||
"Paired devices"
|
||||
} else {
|
||||
"Nearby devices"
|
||||
},
|
||||
14.,
|
||||
));
|
||||
if !devices.iter().any(|d| d.paired == paired) {
|
||||
body = body.push(self.connection_note(if paired {
|
||||
"No paired devices yet."
|
||||
@@ -124,7 +146,10 @@ impl App {
|
||||
"{state} · {}{}{}",
|
||||
device.address,
|
||||
if device.trusted { " · Trusted" } else { "" },
|
||||
device.battery.map(|b| format!(" · {b}% battery")).unwrap_or_default()
|
||||
device
|
||||
.battery
|
||||
.map(|b| format!(" · {b}% battery"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let mut controls = row([]).spacing(6);
|
||||
let action = if device.connected {
|
||||
@@ -149,13 +174,19 @@ impl App {
|
||||
if device.paired {
|
||||
controls = controls.push(self.connection_button(
|
||||
if device.trusted { "Untrust" } else { "Trust" },
|
||||
Input::Run(Command::BluetoothTrust(device.path.clone(), !device.trusted)),
|
||||
Input::Run(Command::BluetoothTrust(
|
||||
device.path.clone(),
|
||||
!device.trusted,
|
||||
)),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
controls = controls.push(self.connection_button(
|
||||
"Forget",
|
||||
Input::Forget(Command::BluetoothForget(device.adapter.clone(), device.path.clone())),
|
||||
Input::Forget(Command::BluetoothForget(
|
||||
device.adapter.clone(),
|
||||
device.path.clone(),
|
||||
)),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
|
||||
@@ -142,7 +142,11 @@ impl Wireless {
|
||||
|
||||
#[zbus(property)]
|
||||
fn active_access_point(&self) -> OwnedObjectPath {
|
||||
path(if self.0.lock().unwrap().activated { "/ap" } else { "/" })
|
||||
path(if self.0.lock().unwrap().activated {
|
||||
"/ap"
|
||||
} else {
|
||||
"/"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +166,11 @@ impl NetworkDevice {
|
||||
|
||||
#[zbus(property)]
|
||||
fn state(&self) -> u32 {
|
||||
if self.0.lock().unwrap().activated { 100 } else { 30 }
|
||||
if self.0.lock().unwrap().activated {
|
||||
100
|
||||
} else {
|
||||
30
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect(&self) {
|
||||
@@ -227,7 +235,8 @@ impl Profile {
|
||||
"802-11-wireless".into(),
|
||||
HashMap::from([(
|
||||
"ssid".into(),
|
||||
OwnedValue::try_from(zbus::zvariant::Value::from(b"Test network".as_slice())).unwrap(),
|
||||
OwnedValue::try_from(zbus::zvariant::Value::from(b"Test network".as_slice()))
|
||||
.unwrap(),
|
||||
)]),
|
||||
),
|
||||
(
|
||||
@@ -263,14 +272,26 @@ async fn network_service(bus: &Bus, state: &Arc<Mutex<NetworkState>>) -> Connect
|
||||
server.request_name(NM).await.unwrap();
|
||||
let objects = server.object_server();
|
||||
objects.at(ROOT, Manager(state.clone())).await.unwrap();
|
||||
objects.at("/device", Wireless(state.clone())).await.unwrap();
|
||||
objects.at("/device", NetworkDevice(state.clone())).await.unwrap();
|
||||
objects.at("/ap", AccessPoint).await.unwrap();
|
||||
objects
|
||||
.at("/org/freedesktop/NetworkManager/Settings", Profiles(state.clone()))
|
||||
.at("/device", Wireless(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects
|
||||
.at("/device", NetworkDevice(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects.at("/ap", AccessPoint).await.unwrap();
|
||||
objects
|
||||
.at(
|
||||
"/org/freedesktop/NetworkManager/Settings",
|
||||
Profiles(state.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
objects
|
||||
.at("/profile", Profile(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects.at("/profile", Profile(state.clone())).await.unwrap();
|
||||
objects.at("/active", Active).await.unwrap();
|
||||
server
|
||||
}
|
||||
@@ -303,20 +324,30 @@ async fn wifi_controls_and_missing_bluetooth_are_independent() {
|
||||
assert_eq!(state.updates, 1);
|
||||
assert_eq!(state.scans, 1);
|
||||
assert_eq!(
|
||||
<&str>::try_from(&state.received.as_ref().unwrap()["802-11-wireless-security"]["psk"]).unwrap(),
|
||||
<&str>::try_from(&state.received.as_ref().unwrap()["802-11-wireless-security"]["psk"])
|
||||
.unwrap(),
|
||||
"updated-password"
|
||||
);
|
||||
}
|
||||
assert!(client.snapshot().await.wifi.unwrap().networks[0].connected);
|
||||
client.execute(Action::WifiDisconnect("/device".into())).await.unwrap();
|
||||
client
|
||||
.execute(Action::WifiDisconnect("/device".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!client.snapshot().await.wifi.unwrap().networks[0].connected);
|
||||
let mut unsaved = wifi.networks[0].clone();
|
||||
unsaved.saved = None;
|
||||
client
|
||||
.execute(Action::WifiConnect(unsaved, Zeroizing::new("new-password".into())))
|
||||
.execute(Action::WifiConnect(
|
||||
unsaved,
|
||||
Zeroizing::new("new-password".into()),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(Action::WifiForget("/profile".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
client.execute(Action::WifiForget("/profile".into())).await.unwrap();
|
||||
assert!(client.snapshot().await.wifi.unwrap().saved.is_empty());
|
||||
}
|
||||
|
||||
@@ -446,7 +477,12 @@ struct AgentManager(Arc<Mutex<BluetoothState>>);
|
||||
|
||||
#[zbus::interface(name = "org.bluez.AgentManager1")]
|
||||
impl AgentManager {
|
||||
fn register_agent(&self, agent: OwnedObjectPath, capability: String, #[zbus(header)] header: Header<'_>) {
|
||||
fn register_agent(
|
||||
&self,
|
||||
agent: OwnedObjectPath,
|
||||
capability: String,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) {
|
||||
assert_eq!(capability, "KeyboardDisplay");
|
||||
self.0.lock().unwrap().agent = Some((header.sender().unwrap().to_string(), agent));
|
||||
}
|
||||
@@ -457,9 +493,15 @@ async fn bluetooth_service(bus: &Bus, state: &Arc<Mutex<BluetoothState>>) -> Con
|
||||
server.request_name("org.bluez").await.unwrap();
|
||||
let objects = server.object_server();
|
||||
objects.at("/", zbus::fdo::ObjectManager).await.unwrap();
|
||||
objects.at("/org/bluez", AgentManager(state.clone())).await.unwrap();
|
||||
objects
|
||||
.at("/org/bluez", AgentManager(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects.at(ADAPTER, Adapter(state.clone())).await.unwrap();
|
||||
objects.at(DEVICE, BluetoothDevice(state.clone())).await.unwrap();
|
||||
objects
|
||||
.at(DEVICE, BluetoothDevice(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
server
|
||||
}
|
||||
|
||||
@@ -496,8 +538,14 @@ async fn bluetooth_pairing_controls_and_close_cleanup() {
|
||||
.execute(Action::BluetoothPower(ADAPTER.into(), true))
|
||||
.await
|
||||
.unwrap();
|
||||
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
|
||||
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothScan(ADAPTER.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothScan(ADAPTER.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state.lock().unwrap().starts, 1);
|
||||
let snapshot = client.snapshot().await;
|
||||
assert!(snapshot.wifi.is_err());
|
||||
@@ -505,7 +553,8 @@ async fn bluetooth_pairing_controls_and_close_cleanup() {
|
||||
assert!(bluetooth.adapters[0].powered);
|
||||
assert_eq!(bluetooth.devices[0].name, "Headphones");
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let prompt = wait_for_prompt(&client).await;
|
||||
assert_eq!(prompt.kind, Kind::Confirm(123456));
|
||||
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
|
||||
@@ -523,20 +572,31 @@ async fn bluetooth_pairing_controls_and_close_cleanup() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!state.lock().unwrap().trusted);
|
||||
client.execute(Action::BluetoothConnect(DEVICE.into())).await.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothConnect(DEVICE.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothForget(ADAPTER.into(), DEVICE.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state.lock().unwrap().removed);
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
wait_for_prompt(&client).await;
|
||||
// Window destruction happens on a UI thread outside Tokio's context.
|
||||
let closing = client.clone();
|
||||
std::thread::spawn(move || closing.shutdown()).join().unwrap();
|
||||
std::thread::spawn(move || closing.shutdown())
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!(pairing.await.unwrap().is_err());
|
||||
assert!(client.execute(Action::BluetoothConnect(DEVICE.into())).await.is_err());
|
||||
assert!(
|
||||
client
|
||||
.execute(Action::BluetoothConnect(DEVICE.into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(client.prompt().is_none());
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while state.lock().unwrap().discovering {
|
||||
@@ -556,7 +616,8 @@ async fn bluetooth_agent_recovers_after_service_restart() {
|
||||
let old_server = bluetooth_service(&bus, &state).await;
|
||||
let client = Client::with_connection(bus.connect().await);
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let prompt = wait_for_prompt(&client).await;
|
||||
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
|
||||
pairing.await.unwrap().unwrap();
|
||||
@@ -564,7 +625,8 @@ async fn bluetooth_agent_recovers_after_service_restart() {
|
||||
let new_state = Arc::new(Mutex::new(BluetoothState::default()));
|
||||
let _server = bluetooth_service(&bus, &new_state).await;
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let prompt = wait_for_prompt(&client).await;
|
||||
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
|
||||
pairing.await.unwrap().unwrap();
|
||||
|
||||
@@ -14,9 +14,10 @@ impl App {
|
||||
}
|
||||
self.connections.loading = true;
|
||||
if let Some(client) = self.connections.client.clone() {
|
||||
Task::perform(async move { Box::new(client.snapshot().await) }, |snapshot| {
|
||||
cosmic::Action::App(Message::ConnectionsRefreshed(snapshot))
|
||||
})
|
||||
Task::perform(
|
||||
async move { Box::new(client.snapshot().await) },
|
||||
|snapshot| cosmic::Action::App(Message::ConnectionsRefreshed(snapshot)),
|
||||
)
|
||||
} else {
|
||||
Task::perform(Client::new(), |client| {
|
||||
cosmic::Action::App(Message::ConnectionsReady(client))
|
||||
@@ -24,7 +25,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connections_ready(&mut self, result: Result<Arc<Client>, String>) -> Task<Message> {
|
||||
pub(crate) fn connections_ready(
|
||||
&mut self,
|
||||
result: Result<Arc<Client>, String>,
|
||||
) -> Task<Message> {
|
||||
self.connections.loading = false;
|
||||
match result {
|
||||
Ok(client) => {
|
||||
@@ -80,18 +84,26 @@ impl App {
|
||||
let prompt = self.connections.client.as_ref().and_then(|c| c.prompt());
|
||||
if prompt != self.connections.prompt {
|
||||
self.connections.pairing_input.zeroize();
|
||||
let needs_input = prompt
|
||||
.as_ref()
|
||||
.is_some_and(|p| matches!(p.kind, super::agent::Kind::Pin | super::agent::Kind::Passkey));
|
||||
let needs_input = prompt.as_ref().is_some_and(|p| {
|
||||
matches!(
|
||||
p.kind,
|
||||
super::agent::Kind::Pin | super::agent::Kind::Passkey
|
||||
)
|
||||
});
|
||||
self.connections.prompt = prompt;
|
||||
let scroll = cosmic::iced::widget::scrollable::snap_to(
|
||||
cosmic::widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
|
||||
cosmic::iced::widget::scrollable::RelativeOffset {
|
||||
x: None,
|
||||
y: Some(0.),
|
||||
},
|
||||
);
|
||||
return if needs_input {
|
||||
Task::batch([
|
||||
scroll,
|
||||
cosmic::widget::text_input::focus(cosmic::widget::Id::new("bluetooth-passkey")),
|
||||
cosmic::widget::text_input::focus(cosmic::widget::Id::new(
|
||||
"bluetooth-passkey",
|
||||
)),
|
||||
])
|
||||
} else {
|
||||
scroll
|
||||
@@ -132,8 +144,12 @@ impl App {
|
||||
&& network.security.can_create()
|
||||
&& super::wifi::valid_password(network.security, &self.connections.password)
|
||||
{
|
||||
let password = std::mem::replace(&mut self.connections.password, Zeroizing::new(String::new()));
|
||||
return self.connections_input(Input::Run(Command::WifiConnect(network, password)));
|
||||
let password = std::mem::replace(
|
||||
&mut self.connections.password,
|
||||
Zeroizing::new(String::new()),
|
||||
);
|
||||
return self
|
||||
.connections_input(Input::Run(Command::WifiConnect(network, password)));
|
||||
}
|
||||
}
|
||||
Input::PairingValue(id, value) => {
|
||||
@@ -154,8 +170,12 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(client) = &self.connections.client {
|
||||
let value = accepted
|
||||
.then(|| std::mem::replace(&mut self.connections.pairing_input, Zeroizing::new(String::new())));
|
||||
let value = accepted.then(|| {
|
||||
std::mem::replace(
|
||||
&mut self.connections.pairing_input,
|
||||
Zeroizing::new(String::new()),
|
||||
)
|
||||
});
|
||||
client.reply(id, value);
|
||||
}
|
||||
self.connections.pairing_input.zeroize();
|
||||
@@ -165,7 +185,10 @@ impl App {
|
||||
self.connections.forget = Some(command);
|
||||
return cosmic::iced::widget::scrollable::snap_to(
|
||||
cosmic::widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
|
||||
cosmic::iced::widget::scrollable::RelativeOffset {
|
||||
x: None,
|
||||
y: Some(0.),
|
||||
},
|
||||
);
|
||||
}
|
||||
Input::CancelForget => self.connections.forget = None,
|
||||
@@ -195,9 +218,10 @@ impl App {
|
||||
self.connections.selected = None;
|
||||
self.connections.hidden = false;
|
||||
let epoch = client.epoch();
|
||||
return Task::perform(async move { client.execute_checked(command, epoch).await }, |result| {
|
||||
cosmic::Action::App(Message::ConnectionFinished(result))
|
||||
});
|
||||
return Task::perform(
|
||||
async move { client.execute_checked(command, epoch).await },
|
||||
|result| cosmic::Action::App(Message::ConnectionFinished(result)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
@@ -295,7 +319,10 @@ mod tests {
|
||||
hidden: false,
|
||||
};
|
||||
for message in [
|
||||
Input::Run(Command::WifiConnect(network, Zeroizing::new("never-log-this".into()))),
|
||||
Input::Run(Command::WifiConnect(
|
||||
network,
|
||||
Zeroizing::new("never-log-this".into()),
|
||||
)),
|
||||
Input::Password(Zeroizing::new("never-log-this".into())),
|
||||
Input::PairingValue(1, Zeroizing::new("never-log-this".into())),
|
||||
] {
|
||||
@@ -312,7 +339,9 @@ mod tests {
|
||||
(
|
||||
"/unused/connections-test.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
Some(crate::InitialPage::Connections(super::super::Tab::Bluetooth)),
|
||||
Some(crate::InitialPage::Connections(
|
||||
super::super::Tab::Bluetooth,
|
||||
)),
|
||||
),
|
||||
);
|
||||
assert_eq!(app.page, crate::schema::Page::Connections);
|
||||
|
||||
@@ -187,7 +187,9 @@ impl Client {
|
||||
match command {
|
||||
Command::WifiPower(on) => wifi::power(&self.connection, on).await,
|
||||
Command::WifiScan => wifi::scan(&self.connection).await,
|
||||
Command::WifiConnect(network, password) => wifi::connect(&self.connection, &network, &password).await,
|
||||
Command::WifiConnect(network, password) => {
|
||||
wifi::connect(&self.connection, &network, &password).await
|
||||
}
|
||||
Command::WifiDisconnect(path) => wifi::disconnect(&self.connection, &path).await,
|
||||
Command::WifiForget(path) => wifi::forget(&self.connection, &path).await,
|
||||
Command::BluetoothPower(path, on) => {
|
||||
@@ -199,10 +201,18 @@ impl Client {
|
||||
Command::BluetoothScan(path) => self.discover(path, epoch).await,
|
||||
Command::BluetoothStop => self.stop_discovery().await,
|
||||
Command::BluetoothPair(path) => self.pair(&path, epoch).await,
|
||||
Command::BluetoothConnect(path) => bluetooth::device_call(&self.connection, &path, "Connect").await,
|
||||
Command::BluetoothDisconnect(path) => bluetooth::device_call(&self.connection, &path, "Disconnect").await,
|
||||
Command::BluetoothTrust(path, on) => bluetooth::trust(&self.connection, &path, on).await,
|
||||
Command::BluetoothForget(adapter, path) => bluetooth::forget(&self.connection, &adapter, &path).await,
|
||||
Command::BluetoothConnect(path) => {
|
||||
bluetooth::device_call(&self.connection, &path, "Connect").await
|
||||
}
|
||||
Command::BluetoothDisconnect(path) => {
|
||||
bluetooth::device_call(&self.connection, &path, "Disconnect").await
|
||||
}
|
||||
Command::BluetoothTrust(path, on) => {
|
||||
bluetooth::trust(&self.connection, &path, on).await
|
||||
}
|
||||
Command::BluetoothForget(adapter, path) => {
|
||||
bluetooth::forget(&self.connection, &adapter, &path).await
|
||||
}
|
||||
Command::CancelPairing => self.cancel_pairing().await,
|
||||
}
|
||||
}
|
||||
@@ -238,7 +248,9 @@ impl Drop for Client {
|
||||
}
|
||||
}
|
||||
|
||||
async fn bounded<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
|
||||
async fn bounded<T>(
|
||||
future: impl std::future::Future<Output = Result<T, String>>,
|
||||
) -> Result<T, String> {
|
||||
tokio::time::timeout(Duration::from_secs(10), future)
|
||||
.await
|
||||
.map_err(|_| "The connection service did not respond. Try again.".to_owned())?
|
||||
|
||||
@@ -67,11 +67,14 @@ impl App {
|
||||
),
|
||||
Input::Tab(Tab::Wifi) => (Some(crate::schema::Page::Connections.icon()), false),
|
||||
Input::Tab(Tab::Bluetooth) => (Some("M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"), false),
|
||||
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => {
|
||||
(Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"), false)
|
||||
}
|
||||
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => (
|
||||
Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"),
|
||||
false,
|
||||
),
|
||||
Input::Run(Command::BluetoothStop) => (Some("M6 6h12v12H6z"), false),
|
||||
Input::Run(Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_))
|
||||
Input::Run(
|
||||
Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_),
|
||||
)
|
||||
| Input::Select(_)
|
||||
| Input::SubmitWifi => (Some("M4 12h15 M14 7l5 5-5 5"), false),
|
||||
Input::Run(Command::WifiDisconnect(_) | Command::BluetoothDisconnect(_)) => {
|
||||
@@ -83,7 +86,10 @@ impl App {
|
||||
};
|
||||
let mut content = row([]).spacing(5).align_y(Alignment::Center);
|
||||
if let Some(icon) = icon {
|
||||
content = content.push(visuals::action_icon(icon, if selected { p.accent } else { p.text }));
|
||||
content = content.push(visuals::action_icon(
|
||||
icon,
|
||||
if selected { p.accent } else { p.text },
|
||||
));
|
||||
}
|
||||
if !icon_only {
|
||||
content = content.push(self.label(label.to_owned(), 12.));
|
||||
@@ -112,7 +118,10 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn connection_card<'a>(&self, body: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
pub(super) fn connection_card<'a>(
|
||||
&self,
|
||||
body: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
container(body)
|
||||
.padding([7, 10])
|
||||
@@ -128,8 +137,18 @@ impl App {
|
||||
body = body.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
.push(self.connection_button("Wi-Fi", Input::Tab(Tab::Wifi), true, s.tab == Tab::Wifi))
|
||||
.push(self.connection_button("Bluetooth", Input::Tab(Tab::Bluetooth), true, s.tab == Tab::Bluetooth))
|
||||
.push(self.connection_button(
|
||||
"Wi-Fi",
|
||||
Input::Tab(Tab::Wifi),
|
||||
true,
|
||||
s.tab == Tab::Wifi,
|
||||
))
|
||||
.push(self.connection_button(
|
||||
"Bluetooth",
|
||||
Input::Tab(Tab::Bluetooth),
|
||||
true,
|
||||
s.tab == Tab::Bluetooth,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
.push(self.connection_button("Refresh", Input::Refresh, !s.loading, false)),
|
||||
);
|
||||
@@ -147,7 +166,12 @@ impl App {
|
||||
.spacing(6)
|
||||
.push(self.label(busy, 12.));
|
||||
if busy == "Pairing…" {
|
||||
status = status.push(self.connection_button("Cancel", Input::Run(Command::CancelPairing), true, false));
|
||||
status = status.push(self.connection_button(
|
||||
"Cancel",
|
||||
Input::Run(Command::CancelPairing),
|
||||
true,
|
||||
false,
|
||||
));
|
||||
}
|
||||
body = body.push(status);
|
||||
}
|
||||
|
||||
@@ -101,12 +101,18 @@ fn security(flags: u32, wpa: u32, rsn: u32) -> Security {
|
||||
|
||||
pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||
let enabled = root.get_property("WirelessEnabled").await.map_err(|e| e.to_string())?;
|
||||
let enabled = root
|
||||
.get_property("WirelessEnabled")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let hardware_enabled = root
|
||||
.get_property("WirelessHardwareEnabled")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
|
||||
let devices: Vec<OwnedObjectPath> = root
|
||||
.call("GetDevices", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let saved = profiles(connection).await?;
|
||||
let mut result = Wifi {
|
||||
enabled,
|
||||
@@ -120,7 +126,10 @@ pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
|
||||
continue;
|
||||
}
|
||||
let name: String = device.get_property("Interface").await.map_err(|e| e.to_string())?;
|
||||
let name: String = device
|
||||
.get_property("Interface")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
result.radios.push(Radio {
|
||||
path: path.to_string(),
|
||||
name,
|
||||
@@ -128,7 +137,10 @@ pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
let state: u32 = device.get_property("State").await.map_err(|e| e.to_string())?;
|
||||
let state: u32 = device
|
||||
.get_property("State")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let wireless = proxy(connection, NM, path.as_str(), WIRELESS).await?;
|
||||
let active: OwnedObjectPath = wireless
|
||||
.get_property("ActiveAccessPoint")
|
||||
@@ -144,10 +156,19 @@ pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
if ssid.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let strength = ap.get_property("Strength").await.map_err(|e| e.to_string())?;
|
||||
let strength = ap
|
||||
.get_property("Strength")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let flags: u32 = ap.get_property("Flags").await.map_err(|e| e.to_string())?;
|
||||
let wpa: u32 = ap.get_property("WpaFlags").await.map_err(|e| e.to_string())?;
|
||||
let rsn: u32 = ap.get_property("RsnFlags").await.map_err(|e| e.to_string())?;
|
||||
let wpa: u32 = ap
|
||||
.get_property("WpaFlags")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rsn: u32 = ap
|
||||
.get_property("RsnFlags")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let auth = security(flags, wpa, rsn);
|
||||
let saved = result
|
||||
.saved
|
||||
@@ -187,17 +208,31 @@ fn compatible(saved: Security, advertised: Security, auth: u32) -> bool {
|
||||
|
||||
fn insert_network(networks: &mut BTreeMap<(Vec<u8>, Security, String), Network>, network: Network) {
|
||||
// Keep adapters separate, and prefer the active AP over a stronger same-SSID AP.
|
||||
let key = (network.ssid.clone(), network.security, network.device.clone());
|
||||
let key = (
|
||||
network.ssid.clone(),
|
||||
network.security,
|
||||
network.device.clone(),
|
||||
);
|
||||
if networks.get(&key).is_none_or(|old| {
|
||||
(network.connected, network.connecting, network.strength) > (old.connected, old.connecting, old.strength)
|
||||
(network.connected, network.connecting, network.strength)
|
||||
> (old.connected, old.connecting, old.strength)
|
||||
}) {
|
||||
networks.insert(key, network);
|
||||
}
|
||||
}
|
||||
|
||||
async fn profiles(connection: &Connection) -> Result<Vec<Saved>, String> {
|
||||
let settings = proxy(connection, NM, "/org/freedesktop/NetworkManager/Settings", SETTINGS).await?;
|
||||
let paths: Vec<OwnedObjectPath> = settings.call("ListConnections", &()).await.map_err(|e| e.to_string())?;
|
||||
let settings = proxy(
|
||||
connection,
|
||||
NM,
|
||||
"/org/freedesktop/NetworkManager/Settings",
|
||||
SETTINGS,
|
||||
)
|
||||
.await?;
|
||||
let paths: Vec<OwnedObjectPath> = settings
|
||||
.call("ListConnections", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut saved = Vec::new();
|
||||
for path in paths {
|
||||
let profile = proxy(connection, NM, path.as_str(), PROFILE).await?;
|
||||
@@ -252,7 +287,10 @@ pub(crate) async fn power(connection: &Connection, enabled: bool) -> Result<(),
|
||||
|
||||
pub(crate) async fn scan(connection: &Connection) -> Result<(), String> {
|
||||
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
|
||||
let devices: Vec<OwnedObjectPath> = root
|
||||
.call("GetDevices", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut found = false;
|
||||
for path in devices {
|
||||
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
|
||||
@@ -331,7 +369,11 @@ fn settings<'a>(
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub(crate) async fn connect(connection: &Connection, network: &Network, password: &str) -> Result<(), String> {
|
||||
pub(crate) async fn connect(
|
||||
connection: &Connection,
|
||||
network: &Network,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||
let device = OwnedObjectPath::try_from(network.device.as_str()).map_err(|e| e.to_string())?;
|
||||
let ap = OwnedObjectPath::try_from(network.ap.as_str()).map_err(|e| e.to_string())?;
|
||||
@@ -341,7 +383,10 @@ pub(crate) async fn connect(connection: &Connection, network: &Network, password
|
||||
return Err("Invalid network password.".into());
|
||||
}
|
||||
let profile = proxy(connection, NM, saved, PROFILE).await?;
|
||||
let previous: Settings = profile.call("GetSettings", &()).await.map_err(|e| e.to_string())?;
|
||||
let previous: Settings = profile
|
||||
.call("GetSettings", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut values: HashMap<String, HashMap<String, Value<'_>>> = previous
|
||||
.into_iter()
|
||||
.map(|(section, values)| {
|
||||
@@ -383,9 +428,17 @@ pub(crate) async fn connect(connection: &Connection, network: &Network, password
|
||||
)
|
||||
.await?;
|
||||
for _ in 0..90 {
|
||||
match active.get_property::<u32>("State").await.map_err(|e| e.to_string())? {
|
||||
match active
|
||||
.get_property::<u32>("State")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
2 => return Ok(()),
|
||||
3 | 4 => return Err("The network connection failed. Check the password and try again.".into()),
|
||||
3 | 4 => {
|
||||
return Err(
|
||||
"The network connection failed. Check the password and try again.".into(),
|
||||
);
|
||||
}
|
||||
_ => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
|
||||
}
|
||||
}
|
||||
@@ -464,10 +517,17 @@ mod tests {
|
||||
let mut network = network();
|
||||
network.hidden = true;
|
||||
let values = settings(&network, "password").unwrap();
|
||||
assert_eq!(values["802-11-wireless-security"]["psk"], Value::from("password"));
|
||||
assert_eq!(
|
||||
values["802-11-wireless-security"]["psk"],
|
||||
Value::from("password")
|
||||
);
|
||||
assert_eq!(values["802-11-wireless"]["hidden"], Value::from(true));
|
||||
network.security = Security::Open;
|
||||
assert!(!settings(&network, "").unwrap().contains_key("802-11-wireless-security"));
|
||||
assert!(
|
||||
!settings(&network, "")
|
||||
.unwrap()
|
||||
.contains_key("802-11-wireless-security")
|
||||
);
|
||||
network.security = Security::Enterprise;
|
||||
assert!(settings(&network, "password").is_err());
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ use super::{Command, Security};
|
||||
use crate::{App, Message, visuals};
|
||||
|
||||
impl App {
|
||||
pub(super) fn wifi_view(&self, result: &Result<super::wifi::Wifi, String>) -> Element<'_, Message> {
|
||||
pub(super) fn wifi_view(
|
||||
&self,
|
||||
result: &Result<super::wifi::Wifi, String>,
|
||||
) -> Element<'_, Message> {
|
||||
let s = &self.connections;
|
||||
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let ready = s.busy.is_none();
|
||||
@@ -46,8 +49,10 @@ impl App {
|
||||
ferese_theme::controls::switch(wifi.enabled, p)
|
||||
.name("Wi-Fi power")
|
||||
.on_press_maybe(
|
||||
(ready && (wifi.hardware_enabled || wifi.enabled) && !wifi.radios.is_empty())
|
||||
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
|
||||
(ready
|
||||
&& (wifi.hardware_enabled || wifi.enabled)
|
||||
&& !wifi.radios.is_empty())
|
||||
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -62,7 +67,8 @@ impl App {
|
||||
.push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)),
|
||||
);
|
||||
if wifi.networks.is_empty() {
|
||||
body = body.push(self.connection_note("No networks found. Scan to look for nearby networks."));
|
||||
body = body
|
||||
.push(self.connection_note("No networks found. Scan to look for nearby networks."));
|
||||
}
|
||||
for network in &wifi.networks {
|
||||
let radio = wifi
|
||||
@@ -98,20 +104,33 @@ impl App {
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
} else if network.saved.is_some() || !network.security.password() && network.security.can_create() {
|
||||
} else if network.saved.is_some()
|
||||
|| !network.security.password() && network.security.can_create()
|
||||
{
|
||||
controls = controls.push(self.connection_button(
|
||||
"Connect",
|
||||
Input::Run(Command::WifiConnect(network.clone(), Zeroizing::new(String::new()))),
|
||||
Input::Run(Command::WifiConnect(
|
||||
network.clone(),
|
||||
Zeroizing::new(String::new()),
|
||||
)),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
} else {
|
||||
controls =
|
||||
controls.push(self.connection_button("Connect…", Input::Select(network.clone()), ready, false));
|
||||
controls = controls.push(self.connection_button(
|
||||
"Connect…",
|
||||
Input::Select(network.clone()),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
}
|
||||
if network.saved.is_some() && network.security.password() {
|
||||
controls =
|
||||
controls.push(self.connection_button("Password…", Input::Select(network.clone()), ready, false));
|
||||
controls = controls.push(self.connection_button(
|
||||
"Password…",
|
||||
Input::Select(network.clone()),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
}
|
||||
body = body.push(
|
||||
self.connection_card(
|
||||
@@ -140,7 +159,12 @@ impl App {
|
||||
body = body.push(self.wifi_credentials());
|
||||
}
|
||||
}
|
||||
body = body.push(self.connection_button("Join a hidden network…", Input::Hidden(true), ready, false));
|
||||
body = body.push(self.connection_button(
|
||||
"Join a hidden network…",
|
||||
Input::Hidden(true),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
if s.hidden {
|
||||
body = body.push(self.wifi_credentials());
|
||||
}
|
||||
@@ -152,7 +176,10 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::action_icon(crate::schema::Page::Connections.icon(), p.muted))
|
||||
.push(visuals::action_icon(
|
||||
crate::schema::Page::Connections.icon(),
|
||||
p.muted,
|
||||
))
|
||||
.push(
|
||||
column([])
|
||||
.spacing(2)
|
||||
@@ -183,7 +210,10 @@ impl App {
|
||||
} else {
|
||||
format!(
|
||||
"Join {}",
|
||||
s.selected.as_ref().map(|n| n.name.as_str()).unwrap_or("network")
|
||||
s.selected
|
||||
.as_ref()
|
||||
.map(|n| n.name.as_str())
|
||||
.unwrap_or("network")
|
||||
)
|
||||
},
|
||||
16.,
|
||||
@@ -198,7 +228,12 @@ impl App {
|
||||
.on_input(|name| message(Input::HiddenName(name))),
|
||||
);
|
||||
let mut choices = row([]).spacing(6);
|
||||
for security in [Security::Personal, Security::Sae, Security::Open, Security::Owe] {
|
||||
for security in [
|
||||
Security::Personal,
|
||||
Security::Sae,
|
||||
Security::Open,
|
||||
Security::Owe,
|
||||
] {
|
||||
choices = choices.push(self.connection_button(
|
||||
security.label(),
|
||||
Input::HiddenSecurity(security),
|
||||
@@ -208,7 +243,10 @@ impl App {
|
||||
}
|
||||
body = body.push(choices);
|
||||
}
|
||||
let security = target.as_ref().map(|n| n.security).unwrap_or(s.hidden_security);
|
||||
let security = target
|
||||
.as_ref()
|
||||
.map(|n| n.security)
|
||||
.unwrap_or(s.hidden_security);
|
||||
if security.password() {
|
||||
body = body.push(
|
||||
text_input("Network password", s.password.as_str())
|
||||
|
||||
@@ -19,7 +19,12 @@ impl Mode {
|
||||
}
|
||||
|
||||
pub fn config(self) -> String {
|
||||
format!("{}x{}@{:.3}", self.width, self.height, f64::from(self.refresh) / 1000.)
|
||||
format!(
|
||||
"{}x{}@{:.3}",
|
||||
self.width,
|
||||
self.height,
|
||||
f64::from(self.refresh) / 1000.
|
||||
)
|
||||
}
|
||||
|
||||
pub fn label(self) -> String {
|
||||
@@ -131,7 +136,9 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -
|
||||
.find(|d| d.connector == matcher || d.identity == matcher);
|
||||
targets.push(Target {
|
||||
key: format!("{name}\0{matcher}"),
|
||||
title: live.map(|d| d.connector.clone()).unwrap_or_else(|| matcher.clone()),
|
||||
title: live
|
||||
.map(|d| d.connector.clone())
|
||||
.unwrap_or_else(|| matcher.clone()),
|
||||
matcher,
|
||||
profile: Some(name.clone()),
|
||||
prefix: Some(prefix),
|
||||
@@ -226,7 +233,10 @@ pub(super) fn change_resolution(
|
||||
resolution: Resolution,
|
||||
automatic: bool,
|
||||
) -> Option<(Mode, bool)> {
|
||||
let modes = choices(display, &format!("{}x{}", resolution.width, resolution.height));
|
||||
let modes = choices(
|
||||
display,
|
||||
&format!("{}x{}", resolution.width, resolution.height),
|
||||
);
|
||||
let auto = automatic && modes.iter().any(|m| (59_000..=61_000).contains(&m.refresh));
|
||||
let previous = configured
|
||||
.split('@')
|
||||
@@ -327,7 +337,10 @@ mod tests {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(!snapshot.boolean(&format!("{prefix}.auto_refresh"), true));
|
||||
assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "[email protected]");
|
||||
assert_eq!(
|
||||
snapshot.string(&format!("{prefix}.mode"), ""),
|
||||
"[email protected]"
|
||||
);
|
||||
assert_eq!(snapshot.number(&format!("{prefix}.scale"), 1.), 1.75);
|
||||
for edit in edits(
|
||||
prefix,
|
||||
@@ -341,14 +354,19 @@ mod tests {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(snapshot.boolean(&format!("{prefix}.auto_refresh"), false));
|
||||
assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "[email protected]");
|
||||
assert_eq!(
|
||||
snapshot.string(&format!("{prefix}.mode"), ""),
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_display_prefers_active_profile_and_survives_profile_reordering() {
|
||||
let snapshot =
|
||||
Snapshot::parse("output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }".into())
|
||||
.unwrap();
|
||||
let snapshot = Snapshot::parse(
|
||||
"output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }"
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let live = vec![Display {
|
||||
connector: "eDP-1".into(),
|
||||
identity: "panel".into(),
|
||||
@@ -359,13 +377,18 @@ mod tests {
|
||||
modes: vec![],
|
||||
}];
|
||||
let values = targets(&snapshot, &live);
|
||||
assert_eq!(selected(&values, None).unwrap().profile.as_deref(), Some("laptop"));
|
||||
assert_eq!(
|
||||
selected(&values, None).unwrap().profile.as_deref(),
|
||||
Some("laptop")
|
||||
);
|
||||
let dock = selected(&values, Some("dock\0HDMI-A-1")).unwrap();
|
||||
assert!(!dock.connected);
|
||||
let key = dock.key.clone();
|
||||
let reordered =
|
||||
Snapshot::parse("output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }".into())
|
||||
.unwrap();
|
||||
let reordered = Snapshot::parse(
|
||||
"output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }"
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let values = targets(&reordered, &live);
|
||||
assert_eq!(
|
||||
selected(&values, Some(&key)).unwrap().prefix.as_deref(),
|
||||
@@ -392,7 +415,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolution_changes_preserve_refresh_and_only_keep_valid_auto_policy() {
|
||||
let mode = |width, height, refresh| Mode { width, height, refresh };
|
||||
let mode = |width, height, refresh| Mode {
|
||||
width,
|
||||
height,
|
||||
refresh,
|
||||
};
|
||||
let display = Display {
|
||||
connector: "eDP-1".into(),
|
||||
identity: "panel".into(),
|
||||
@@ -427,6 +454,17 @@ mod tests {
|
||||
change_resolution(&display, "2880x1800@120", other, true),
|
||||
Some((mode(2560, 1440, 90000), false))
|
||||
);
|
||||
assert!(change_resolution(&display, "", Resolution { width: 1, height: 1 }, true).is_none());
|
||||
assert!(
|
||||
change_resolution(
|
||||
&display,
|
||||
"",
|
||||
Resolution {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
true
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,17 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::action_icon("M3 4h18v13H3z M12 17v4 M8 21h8", palette.accent))
|
||||
.push(visuals::action_icon(
|
||||
"M3 4h18v13H3z M12 17v4 M8 21h8",
|
||||
palette.accent,
|
||||
))
|
||||
.push(
|
||||
ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(targets.clone(), selected.clone(), |target| {
|
||||
Message::SelectDisplay(target.key)
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
targets.clone(),
|
||||
selected.clone(),
|
||||
|target| Message::SelectDisplay(target.key),
|
||||
)
|
||||
.placeholder("Select display")
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
@@ -86,7 +91,9 @@ impl App {
|
||||
}
|
||||
let modes = displays::choices(display, &configured);
|
||||
let mut refresh: Vec<_> = modes.iter().copied().map(Refresh::Manual).collect();
|
||||
if modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh))
|
||||
if modes
|
||||
.iter()
|
||||
.any(|mode| (59_000..=61_000).contains(&mode.refresh))
|
||||
&& let Some(high) = modes.last()
|
||||
{
|
||||
refresh.push(Refresh::Automatic(*high));
|
||||
@@ -101,7 +108,9 @@ impl App {
|
||||
let selected = refresh.iter().copied().find(|value| match value {
|
||||
Refresh::Automatic(_) => automatic,
|
||||
Refresh::Manual(mode) => {
|
||||
!automatic && configured_rate.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.)
|
||||
!automatic
|
||||
&& configured_rate
|
||||
.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.)
|
||||
}
|
||||
});
|
||||
let path = prefix.clone();
|
||||
@@ -109,10 +118,18 @@ impl App {
|
||||
self.display_control(
|
||||
"Refresh rate",
|
||||
ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(refresh, selected, move |value| match value {
|
||||
Refresh::Manual(mode) => Message::RefreshRate(path.clone(), mode, false),
|
||||
Refresh::Automatic(mode) => Message::RefreshRate(path.clone(), mode, true),
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
refresh,
|
||||
selected,
|
||||
move |value| match value {
|
||||
Refresh::Manual(mode) => {
|
||||
Message::RefreshRate(path.clone(), mode, false)
|
||||
}
|
||||
Refresh::Automatic(mode) => {
|
||||
Message::RefreshRate(path.clone(), mode, true)
|
||||
}
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
.padding([4, 8])
|
||||
@@ -157,7 +174,11 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn display_control<'a>(&self, label: &str, control: Element<'a, Message>) -> Element<'a, Message> {
|
||||
fn display_control<'a>(
|
||||
&self,
|
||||
label: &str,
|
||||
control: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
row([])
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
@@ -168,6 +189,11 @@ impl App {
|
||||
}
|
||||
|
||||
fn display_mode_field(&self, prefix: &str) -> Element<'static, Message> {
|
||||
self.field(schema::text(format!("{prefix}.mode"), "Resolution / refresh", "", ""))
|
||||
self.field(schema::text(
|
||||
format!("{prefix}.mode"),
|
||||
"Resolution / refresh",
|
||||
"",
|
||||
"",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use cosmic::widget::column;
|
||||
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals,
|
||||
widget,
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider,
|
||||
text_input, visuals, widget,
|
||||
};
|
||||
|
||||
impl App {
|
||||
@@ -39,7 +39,11 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn control_button_style(&self, palette: visuals::Palette, selected: bool) -> cosmic::theme::Button {
|
||||
fn control_button_style(
|
||||
&self,
|
||||
palette: visuals::Palette,
|
||||
selected: bool,
|
||||
) -> cosmic::theme::Button {
|
||||
if self.page == crate::schema::Page::Bar {
|
||||
visuals::panel_button(palette, selected)
|
||||
} else {
|
||||
@@ -61,7 +65,11 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::action_icon(
|
||||
icon,
|
||||
if message.is_some() { palette.text } else { palette.muted },
|
||||
if message.is_some() {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.push(self.label(label.to_owned(), 12.)),
|
||||
)
|
||||
@@ -82,7 +90,11 @@ impl App {
|
||||
widget::tooltip(
|
||||
button::custom(visuals::action_icon(
|
||||
icon,
|
||||
if message.is_some() { palette.text } else { palette.muted },
|
||||
if message.is_some() {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.name(label.to_owned())
|
||||
.padding(4)
|
||||
@@ -102,13 +114,16 @@ impl App {
|
||||
ferese_config::theme::Appearance::Dark => "dark",
|
||||
};
|
||||
field.path = format!("theme.{name}.material.opacity");
|
||||
field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
field.description =
|
||||
format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
if let Kind::Range { default, .. } = &mut field.kind {
|
||||
*default = self.resolved.theme.tokens.material.opacity;
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut labels = column([]).spacing(2).push(self.label(field.label.clone(), 13.));
|
||||
let mut labels = column([])
|
||||
.spacing(2)
|
||||
.push(self.label(field.label.clone(), 13.));
|
||||
if !field.description.is_empty() {
|
||||
labels = labels.push(
|
||||
self.label(field.description.clone(), 11.)
|
||||
@@ -121,7 +136,11 @@ impl App {
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.name(format!(
|
||||
"{}: {}",
|
||||
field.label,
|
||||
if enabled { "on" } else { "off" }
|
||||
))
|
||||
.on_press(Message::Change(set(&path, !enabled)))
|
||||
.into()
|
||||
}
|
||||
@@ -133,7 +152,8 @@ impl App {
|
||||
suffix,
|
||||
integer,
|
||||
} => {
|
||||
let margin_limit = (path == "panels.0.geometry.side_margins").then(|| self.panel_side_margin_limit());
|
||||
let margin_limit = (path == "panels.0.geometry.side_margins")
|
||||
.then(|| self.panel_side_margin_limit());
|
||||
if let Some(limit) = margin_limit {
|
||||
max = f64::from(limit.unwrap_or(0));
|
||||
}
|
||||
@@ -158,11 +178,13 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(8)
|
||||
.push(
|
||||
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
slider(min..=max, value, move |value| {
|
||||
Message::Range(field.clone(), value)
|
||||
})
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
)
|
||||
.push(self.label(display, 12.).width(65))
|
||||
.into()
|
||||
@@ -208,7 +230,10 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
|
||||
.push(
|
||||
container(widget::Space::new().width(24).height(24))
|
||||
.class(visuals::surface(swatch, 6.)),
|
||||
)
|
||||
.push(input)
|
||||
.into()
|
||||
} else {
|
||||
|
||||
@@ -54,8 +54,12 @@ fn main() -> cosmic::iced::Result {
|
||||
}
|
||||
Some("--page") => {
|
||||
initial_page = match args.next().as_deref().and_then(|s| s.to_str()) {
|
||||
Some("connections" | "wifi") => Some(InitialPage::Connections(connections::Tab::Wifi)),
|
||||
Some("bluetooth") => Some(InitialPage::Connections(connections::Tab::Bluetooth)),
|
||||
Some("connections" | "wifi") => {
|
||||
Some(InitialPage::Connections(connections::Tab::Wifi))
|
||||
}
|
||||
Some("bluetooth") => {
|
||||
Some(InitialPage::Connections(connections::Tab::Bluetooth))
|
||||
}
|
||||
Some("wallpaper") => Some(InitialPage::Wallpaper),
|
||||
Some("bar") => Some(InitialPage::Bar),
|
||||
_ => {
|
||||
@@ -65,7 +69,9 @@ fn main() -> cosmic::iced::Result {
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]");
|
||||
eprintln!(
|
||||
"Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -144,7 +150,10 @@ enum Message {
|
||||
WallpaperTarget(wallpaper_controls::Target),
|
||||
UseDefaultWallpaper(ferese_config::theme::Appearance),
|
||||
RestoreDefaultWallpapers,
|
||||
DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result<widget::image::Handle, String>),
|
||||
DefaultWallpaperThumbnail(
|
||||
ferese_config::theme::Appearance,
|
||||
Result<widget::image::Handle, String>,
|
||||
),
|
||||
PreviewLock,
|
||||
LockPreviewStarted(Result<(), String>),
|
||||
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
|
||||
@@ -295,7 +304,13 @@ impl cosmic::Application for App {
|
||||
native_palette,
|
||||
gallery_focus: None,
|
||||
family_sections,
|
||||
family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()),
|
||||
family_ids: std::sync::Arc::new(
|
||||
resolved
|
||||
.families
|
||||
.iter()
|
||||
.map(|family| family.id.clone())
|
||||
.collect(),
|
||||
),
|
||||
resolved,
|
||||
undo_revision: 0,
|
||||
auto_details: false,
|
||||
@@ -352,7 +367,8 @@ impl cosmic::Application for App {
|
||||
.displays
|
||||
.iter()
|
||||
.find(|d| d.connector == matcher || d.identity == matcher)
|
||||
&& let Some((mode, automatic)) = displays::change_resolution(display, &configured, size, automatic)
|
||||
&& let Some((mode, automatic)) =
|
||||
displays::change_resolution(display, &configured, size, automatic)
|
||||
{
|
||||
self.inputs.remove(&format!("{prefix}.mode"));
|
||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||
@@ -371,18 +387,26 @@ impl cosmic::Application for App {
|
||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||
}
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let wallpaper_changed =
|
||||
self.resolved.presented.tokens.background.path != snapshot.presented.tokens.background.path;
|
||||
let wallpaper_changed = self.resolved.presented.tokens.background.path
|
||||
!= snapshot.presented.tokens.background.path;
|
||||
let font_changed = self.resolved.presented.tokens.typography.font_family
|
||||
!= snapshot.presented.tokens.typography.font_family;
|
||||
if self.resolved.families != snapshot.families {
|
||||
self.family_ids =
|
||||
std::sync::Arc::new(snapshot.families.iter().map(|family| family.id.clone()).collect());
|
||||
self.family_sections = theme_controls::FamilySections::new(&snapshot.families, &self.draft);
|
||||
self.family_ids = std::sync::Arc::new(
|
||||
snapshot
|
||||
.families
|
||||
.iter()
|
||||
.map(|family| family.id.clone())
|
||||
.collect(),
|
||||
);
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&snapshot.families, &self.draft);
|
||||
}
|
||||
self.resolved = *snapshot;
|
||||
if font_changed {
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
}
|
||||
|
||||
let theme = self.update_theme();
|
||||
@@ -393,16 +417,28 @@ impl cosmic::Application for App {
|
||||
};
|
||||
}
|
||||
Message::ExternalConfig(result) => match result {
|
||||
Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
|
||||
Ok(snapshot)
|
||||
if snapshot.source == self.current.source
|
||||
|| snapshot.source == self.draft.source => {}
|
||||
Ok(snapshot) => {
|
||||
if self.saving || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() {
|
||||
self.status = "Config changed externally · Reload when your edits are finished".into();
|
||||
if self.saving
|
||||
|| !self.inputs.is_empty()
|
||||
|| !self.ranges.is_empty()
|
||||
|| !self.pending.is_empty()
|
||||
{
|
||||
self.status =
|
||||
"Config changed externally · Reload when your edits are finished"
|
||||
.into();
|
||||
} else {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.family_sections = theme_controls::FamilySections::new(
|
||||
&self.resolved.families,
|
||||
&self.draft,
|
||||
);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.undo = None;
|
||||
self.error = None;
|
||||
self.status = "Updated from your config".into();
|
||||
@@ -442,7 +478,9 @@ impl cosmic::Application for App {
|
||||
Message::Sidebar(open) => {
|
||||
self.sidebar_open = open;
|
||||
if open {
|
||||
return cosmic::iced::widget::operation::focus(widget::Id::new("settings-search"));
|
||||
return cosmic::iced::widget::operation::focus(widget::Id::new(
|
||||
"settings-search",
|
||||
));
|
||||
}
|
||||
}
|
||||
Message::Page(page) => {
|
||||
@@ -504,7 +542,9 @@ impl cosmic::Application for App {
|
||||
{
|
||||
let (id, appearance) = self.gallery_focus.take().unwrap();
|
||||
return cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(
|
||||
gallery_id(&id, appearance),
|
||||
),
|
||||
)
|
||||
.map(cosmic::Action::App);
|
||||
}
|
||||
@@ -532,12 +572,17 @@ impl cosmic::Application for App {
|
||||
Message::Release(field) => {
|
||||
if let Some(value) = self.ranges.remove(&field.path) {
|
||||
if field.path == "panels.0.background_opacity" {
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity(value as f32 / 100.)));
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity(
|
||||
value as f32 / 100.,
|
||||
)));
|
||||
}
|
||||
if let Some(index) = field.path.strip_prefix("panels.0.corner_radius.")
|
||||
&& let Ok(index) = index.parse()
|
||||
{
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetCorner(index, value as f32)));
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetCorner(
|
||||
index,
|
||||
value as f32,
|
||||
)));
|
||||
}
|
||||
let integer = matches!(field.kind, Kind::Range { integer: true, .. });
|
||||
if let Some(key) = field.path.strip_prefix("panels.0.geometry.") {
|
||||
@@ -546,7 +591,10 @@ impl cosmic::Application for App {
|
||||
} else {
|
||||
value.into()
|
||||
};
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry(key.into(), value)));
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry(
|
||||
key.into(),
|
||||
value,
|
||||
)));
|
||||
}
|
||||
return self.change(if integer {
|
||||
set(&field.path, value.round() as i64)
|
||||
@@ -575,7 +623,9 @@ impl cosmic::Application for App {
|
||||
match shlex::split(&value).filter(|v| !v.is_empty()) {
|
||||
Some(args) => set(
|
||||
&field.path,
|
||||
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>(),
|
||||
args.into_iter()
|
||||
.map(serde_json::Value::from)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
None => {
|
||||
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
|
||||
@@ -618,7 +668,9 @@ impl cosmic::Application for App {
|
||||
self.panel_group_selection = None;
|
||||
}
|
||||
Message::PanelPreviewResolved(resolution) => self.panel_preview_resolution = resolution,
|
||||
Message::PanelPreviewOverflow => self.panel_preview_overflow = !self.panel_preview_overflow,
|
||||
Message::PanelPreviewOverflow => {
|
||||
self.panel_preview_overflow = !self.panel_preview_overflow
|
||||
}
|
||||
Message::PanelPage(tab) => {
|
||||
self.panel_page = tab;
|
||||
return cosmic::iced::widget::scrollable::snap_to(
|
||||
@@ -632,13 +684,20 @@ impl cosmic::Application for App {
|
||||
self.ranges.retain(|path, _| !path.starts_with("panels."));
|
||||
let task = self.edit_many(edits);
|
||||
if let Ok(panel) = self.preview_panel() {
|
||||
if self.panel_selection.as_ref().is_some_and(|id| panel.item(id).is_none()) {
|
||||
if self
|
||||
.panel_selection
|
||||
.as_ref()
|
||||
.is_some_and(|id| panel.item(id).is_none())
|
||||
{
|
||||
self.panel_selection = None;
|
||||
}
|
||||
if self.panel_group_selection.as_ref().is_some_and(|id| {
|
||||
!panel_edit::Zone::ALL
|
||||
.into_iter()
|
||||
.any(|zone| zone.definition(&panel).groups.iter().any(|group| &group.id == id))
|
||||
!panel_edit::Zone::ALL.into_iter().any(|zone| {
|
||||
zone.definition(&panel)
|
||||
.groups
|
||||
.iter()
|
||||
.any(|group| &group.id == id)
|
||||
})
|
||||
}) {
|
||||
self.panel_group_selection = None;
|
||||
}
|
||||
@@ -661,7 +720,9 @@ impl cosmic::Application for App {
|
||||
Ok((snapshot, live)) => {
|
||||
self.undo = Some(std::mem::take(&mut self.saving_previous));
|
||||
self.current = snapshot;
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.draft = self.current.clone();
|
||||
|
||||
for edit in &self.pending {
|
||||
@@ -670,8 +731,10 @@ impl cosmic::Application for App {
|
||||
}
|
||||
}
|
||||
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.family_sections = theme_controls::FamilySections::new(
|
||||
&self.resolved.families,
|
||||
&self.draft,
|
||||
);
|
||||
self.status = if live {
|
||||
"Saved · desktop updated"
|
||||
} else if self.path != store::config_path() {
|
||||
@@ -687,7 +750,12 @@ impl cosmic::Application for App {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
Message::ExpireUndo(revision)
|
||||
});
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail(), expire]);
|
||||
return Task::batch([
|
||||
self.update_theme(),
|
||||
self.flush(),
|
||||
self.load_thumbnail(),
|
||||
expire,
|
||||
]);
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
@@ -701,8 +769,11 @@ impl cosmic::Application for App {
|
||||
Ok(snapshot) => {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
@@ -735,7 +806,9 @@ impl cosmic::Application for App {
|
||||
let edits = visuals::choose_family(&id, appearance);
|
||||
let focus = if self.gallery_focus.is_none() {
|
||||
cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(
|
||||
&id, appearance,
|
||||
)),
|
||||
)
|
||||
.map(cosmic::Action::App)
|
||||
} else {
|
||||
@@ -748,7 +821,8 @@ impl cosmic::Application for App {
|
||||
Message::AutoAppearance(enabled) => {
|
||||
let mode = if enabled {
|
||||
"auto"
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light
|
||||
{
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
@@ -780,7 +854,8 @@ impl cosmic::Application for App {
|
||||
let directory = self.path.parent().map(ToOwned::to_owned);
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")? else {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut snapshot = Snapshot::parse(source)?;
|
||||
@@ -796,18 +871,26 @@ impl cosmic::Application for App {
|
||||
.collect();
|
||||
let mut id = format!("custom-{stem}");
|
||||
let mut suffix = 2;
|
||||
while snapshot.item(&format!("theme.custom_themes.{id}")).is_some() {
|
||||
while snapshot
|
||||
.item(&format!("theme.custom_themes.{id}"))
|
||||
.is_some()
|
||||
{
|
||||
id = format!("custom-{stem}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
|
||||
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
snapshot.edit(&set(
|
||||
&format!("theme.custom_themes.{id}.file"),
|
||||
path.as_str(),
|
||||
))?;
|
||||
let mut connection =
|
||||
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let value = connection.call(
|
||||
"theme-preview",
|
||||
serde_json::json!({"source": snapshot.source, "directory": directory}),
|
||||
)?;
|
||||
let _: Vec<ferese_config::families::Family> =
|
||||
serde_json::from_value(value["families"].clone()).map_err(|e| e.to_string())?;
|
||||
serde_json::from_value(value["families"].clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Some((id, path)))
|
||||
})
|
||||
.await
|
||||
@@ -857,7 +940,9 @@ impl cosmic::Application for App {
|
||||
return self.apply_wallpaper_edits(wallpaper_controls::restore_defaults());
|
||||
}
|
||||
Message::NewCommand(value) => self.new_command = value,
|
||||
Message::AddCommand if !self.saving => match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
|
||||
Message::AddCommand if !self.saving => match shlex::split(&self.new_command)
|
||||
.filter(|a| !a.is_empty())
|
||||
{
|
||||
Some(args) => {
|
||||
self.new_command.clear();
|
||||
return self.change(Edit::Add(
|
||||
@@ -865,14 +950,19 @@ impl cosmic::Application for App {
|
||||
vec![
|
||||
(
|
||||
"command".into(),
|
||||
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>().into(),
|
||||
args.into_iter()
|
||||
.map(serde_json::Value::from)
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
),
|
||||
("enabled".into(), true.into()),
|
||||
("restart".into(), true.into()),
|
||||
],
|
||||
));
|
||||
}
|
||||
None => self.error = Some("Enter a program and arguments with balanced quotes.".into()),
|
||||
None => {
|
||||
self.error = Some("Enter a program and arguments with balanced quotes.".into())
|
||||
}
|
||||
},
|
||||
Message::AddSwipe(keys) if !self.saving => {
|
||||
let (action, argument) = match keys {
|
||||
@@ -881,7 +971,10 @@ impl cosmic::Application for App {
|
||||
"Swipe3Left" => ("focus", Some("right")),
|
||||
_ => ("focus", Some("left")),
|
||||
};
|
||||
let mut fields = vec![("keys".into(), keys.into()), ("action".into(), action.into())];
|
||||
let mut fields = vec![
|
||||
("keys".into(), keys.into()),
|
||||
("action".into(), action.into()),
|
||||
];
|
||||
|
||||
if let Some(argument) = argument {
|
||||
fields.push(("argument".into(), argument.into()));
|
||||
@@ -908,18 +1001,23 @@ impl cosmic::Application for App {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Rescaled(scale)) => {
|
||||
Some(Message::RenderScale(scale))
|
||||
}
|
||||
cosmic::iced::Event::PlatformSpecific(cosmic::iced::event::PlatformSpecific::Wayland(
|
||||
cosmic::iced::event::wayland::Event::Output(
|
||||
cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_),
|
||||
_,
|
||||
cosmic::iced::Event::PlatformSpecific(
|
||||
cosmic::iced::event::PlatformSpecific::Wayland(
|
||||
cosmic::iced::event::wayland::Event::Output(
|
||||
cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_),
|
||||
_,
|
||||
),
|
||||
),
|
||||
)) => Some(Message::ReadRenderScale(id)),
|
||||
) => Some(Message::ReadRenderScale(id)),
|
||||
_ => None,
|
||||
}),
|
||||
if self.sidebar_open {
|
||||
cosmic::iced::event::listen_with(|event, _, _| match event {
|
||||
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||
key:
|
||||
cosmic::iced::keyboard::Key::Named(
|
||||
cosmic::iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
..
|
||||
}) => Some(Message::Sidebar(false)),
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Resized(size))
|
||||
@@ -933,9 +1031,11 @@ impl cosmic::Application for App {
|
||||
cosmic::iced::Subscription::none()
|
||||
},
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)),
|
||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
ferese_theme_client::service::subscription()
|
||||
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
if matches!(self.page, Page::Displays | Page::Bar) {
|
||||
cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays)
|
||||
cosmic::iced::time::every(std::time::Duration::from_secs(2))
|
||||
.map(|_| Message::RefreshDisplays)
|
||||
} else {
|
||||
cosmic::iced::Subscription::none()
|
||||
},
|
||||
@@ -993,7 +1093,10 @@ impl App {
|
||||
|
||||
fn change(&mut self, edit: Edit) -> Task<Message> {
|
||||
if let Edit::Set(path, value) = &edit
|
||||
&& matches!(path.as_str(), "theme.background.path" | "theme.background.mode")
|
||||
&& matches!(
|
||||
path.as_str(),
|
||||
"theme.background.path" | "theme.background.mode"
|
||||
)
|
||||
{
|
||||
return self.apply_wallpaper_edits(wallpaper_controls::edits(
|
||||
wallpaper_controls::Target::Both,
|
||||
@@ -1093,7 +1196,8 @@ mod tests {
|
||||
let mut app = app();
|
||||
app.core.set_main_window_id(None);
|
||||
assert_eq!(app.update(Message::DragWindow).units(), 0);
|
||||
app.core.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
|
||||
app.core
|
||||
.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
|
||||
assert_eq!(app.update(Message::DragWindow).units(), 1);
|
||||
}
|
||||
|
||||
@@ -1115,7 +1219,12 @@ mod tests {
|
||||
#[test]
|
||||
fn corner_sliders_hold_changes_until_release_and_preserve_other_corners() {
|
||||
let mut app = app();
|
||||
for edit in panel_edit::plan(&app.draft, panel_edit::Action::SetRadius("4px 8px 12px 16px".into())).unwrap() {
|
||||
for edit in panel_edit::plan(
|
||||
&app.draft,
|
||||
panel_edit::Action::SetRadius("4px 8px 12px 16px".into()),
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
app.draft.edit(&edit).unwrap();
|
||||
}
|
||||
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
|
||||
@@ -1139,7 +1248,8 @@ mod tests {
|
||||
assert_eq!(app.draft.source, before);
|
||||
let _ = app.update(Message::Release(field));
|
||||
let radii: ferese_panel::CornerRadii =
|
||||
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
||||
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone())
|
||||
.unwrap();
|
||||
assert_eq!(radii.0, [4., 8., 23., 16.]);
|
||||
assert!(app.ranges.is_empty());
|
||||
assert_eq!(app.current.source, before);
|
||||
@@ -1150,7 +1260,9 @@ mod tests {
|
||||
use ferese_panel::{GroupId, ItemId};
|
||||
let mut app = app();
|
||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||
initial
|
||||
.edit(&panel_controls::initialize(&initial).unwrap())
|
||||
.unwrap();
|
||||
app.current = initial.clone();
|
||||
app.draft = initial.clone();
|
||||
app.saving = true; // An earlier save has not completed yet.
|
||||
@@ -1183,7 +1295,12 @@ mod tests {
|
||||
panels[0].start.groups[1].items[1].overflow,
|
||||
ferese_panel::OverflowPolicy::Always
|
||||
);
|
||||
assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id));
|
||||
assert!(
|
||||
!panels[0].end.groups[0]
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1232,7 +1349,9 @@ mod tests {
|
||||
use ferese_panel::GroupId;
|
||||
let mut app = app();
|
||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||
initial
|
||||
.edit(&panel_controls::initialize(&initial).unwrap())
|
||||
.unwrap();
|
||||
app.current = initial.clone();
|
||||
app.draft = initial.clone();
|
||||
app.saving = true;
|
||||
@@ -1253,7 +1372,10 @@ mod tests {
|
||||
assert!(app.pending.is_empty());
|
||||
assert_eq!(app.draft.doc.value(), &expected);
|
||||
assert_eq!(app.panel_group_selection, Some(id));
|
||||
assert_eq!(app.draft.number("panels.0.start.groups.2.padding.1", 0.), 12.);
|
||||
assert_eq!(
|
||||
app.draft.number("panels.0.start.groups.2.padding.1", 0.),
|
||||
12.
|
||||
);
|
||||
let _ = app.page_view();
|
||||
}
|
||||
|
||||
@@ -1272,7 +1394,10 @@ mod tests {
|
||||
fn panel_pages_do_not_save_and_selection_opens_the_items_inspector() {
|
||||
let mut app = app();
|
||||
app.page = Page::Bar;
|
||||
for tab in [panel_controls::PanelPage::Arrange, panel_controls::PanelPage::Panels] {
|
||||
for tab in [
|
||||
panel_controls::PanelPage::Arrange,
|
||||
panel_controls::PanelPage::Panels,
|
||||
] {
|
||||
let _ = app.update(Message::PanelPage(tab));
|
||||
let _ = app.page_view();
|
||||
assert_eq!(app.panel_page, tab);
|
||||
@@ -1283,7 +1408,9 @@ mod tests {
|
||||
let _ = app.update(Message::PanelSelect(ferese_panel::ItemId("clock".into())));
|
||||
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
||||
assert!(app.panel_group_selection.is_none());
|
||||
let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId("status".into())));
|
||||
let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId(
|
||||
"status".into(),
|
||||
)));
|
||||
assert!(app.panel_selection.is_none());
|
||||
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
||||
}
|
||||
@@ -1293,7 +1420,9 @@ mod tests {
|
||||
use ferese_panel::{GroupId, ItemId};
|
||||
let mut app = app();
|
||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||
initial
|
||||
.edit(&panel_controls::initialize(&initial).unwrap())
|
||||
.unwrap();
|
||||
app.current = initial.clone();
|
||||
app.draft = initial.clone();
|
||||
app.saving = true;
|
||||
@@ -1314,22 +1443,30 @@ mod tests {
|
||||
let _ = app.update(Message::Saved(Ok((initial, false))));
|
||||
assert_eq!(app.draft.doc.value(), &expected);
|
||||
assert!(app.saving && app.pending.is_empty());
|
||||
assert_eq!(app.draft.string("panels.0.center.groups.1.items.0.id", ""), "network");
|
||||
assert_eq!(app.draft.string("panels.0.center.groups.1.items.1.id", ""), "clock");
|
||||
assert_eq!(
|
||||
app.draft.string("panels.0.center.groups.1.items.0.id", ""),
|
||||
"network"
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.string("panels.0.center.groups.1.items.1.id", ""),
|
||||
"clock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() {
|
||||
let mut app = app();
|
||||
app.path = store::config_path();
|
||||
app.current = Snapshot::parse("theme { background { path \"/saved.png\"; }; }".into()).unwrap();
|
||||
app.current =
|
||||
Snapshot::parse("theme { background { path \"/saved.png\"; }; }".into()).unwrap();
|
||||
app.resolved.presented.tokens.background.path = Some("/active.png".into());
|
||||
assert_eq!(app.active_wallpaper_path(), "/active.png");
|
||||
|
||||
app.page = Page::Wallpaper;
|
||||
let mut snapshot = app.resolved.clone();
|
||||
snapshot.presented.tokens.background.path =
|
||||
Some(ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light).into());
|
||||
snapshot.presented.tokens.background.path = Some(
|
||||
ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light).into(),
|
||||
);
|
||||
let task = app.update(Message::ThemeChanged(Box::new(snapshot)));
|
||||
assert!(task.units() > 0);
|
||||
assert_eq!(
|
||||
@@ -1353,16 +1490,26 @@ mod tests {
|
||||
let mut app = app();
|
||||
app.saving = true;
|
||||
app.wallpaper.target = wallpaper_controls::Target::Light;
|
||||
app.draft = Snapshot::parse("theme { dark { background { path \"/custom-dark.png\"; }; }; }".into()).unwrap();
|
||||
app.inputs
|
||||
.insert("theme.light.background.path".into(), "/unfinished.png".into());
|
||||
let _ = app.update(Message::UseDefaultWallpaper(ferese_config::theme::Appearance::Light));
|
||||
app.draft = Snapshot::parse(
|
||||
"theme { dark { background { path \"/custom-dark.png\"; }; }; }".into(),
|
||||
)
|
||||
.unwrap();
|
||||
app.inputs.insert(
|
||||
"theme.light.background.path".into(),
|
||||
"/unfinished.png".into(),
|
||||
);
|
||||
let _ = app.update(Message::UseDefaultWallpaper(
|
||||
ferese_config::theme::Appearance::Light,
|
||||
));
|
||||
assert!(!app.inputs.contains_key("theme.light.background.path"));
|
||||
assert_eq!(
|
||||
app.draft.string("theme.light.background.path", ""),
|
||||
ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light)
|
||||
);
|
||||
assert_eq!(app.draft.string("theme.dark.background.path", ""), "/custom-dark.png");
|
||||
assert_eq!(
|
||||
app.draft.string("theme.dark.background.path", ""),
|
||||
"/custom-dark.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1374,14 +1521,18 @@ mod tests {
|
||||
wallpaper_controls::Target::Light,
|
||||
Ok(Some("/chosen.png".into())),
|
||||
));
|
||||
assert_eq!(app.draft.string("theme.light.background.path", ""), "/chosen.png");
|
||||
assert_eq!(
|
||||
app.draft.string("theme.light.background.path", ""),
|
||||
"/chosen.png"
|
||||
);
|
||||
assert!(app.draft.item("theme.dark.background.path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_build_controls_only_for_mounted_rows() {
|
||||
let mut app = shortcuts(100);
|
||||
app.visible_rows.extend((0..4).map(|index| ("bindings", index)));
|
||||
app.visible_rows
|
||||
.extend((0..4).map(|index| ("bindings", index)));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -1406,20 +1557,32 @@ mod tests {
|
||||
tree_nodes(&tree)
|
||||
};
|
||||
let visible = nodes(&app);
|
||||
app.visible_rows.extend((0..100).map(|index| ("bindings", index)));
|
||||
app.visible_rows
|
||||
.extend((0..100).map(|index| ("bindings", index)));
|
||||
let eager = nodes(&app);
|
||||
assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}");
|
||||
assert!(
|
||||
eager > visible + 96 * 10,
|
||||
"visible={visible}, eager={eager}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_rows_keep_unsaved_text_mounted_until_unfocus() {
|
||||
let mut app = shortcuts(10);
|
||||
let _ = app.update(Message::RowVisibility("bindings", 5, true));
|
||||
let _ = app.update(Message::Draft("bindings.5.argument".into(), "my command".into()));
|
||||
let _ = app.update(Message::Draft(
|
||||
"bindings.5.argument".into(),
|
||||
"my command".into(),
|
||||
));
|
||||
let _ = app.update(Message::RowVisibility("bindings", 5, false));
|
||||
assert!(app.visible_rows.contains(&("bindings", 5)));
|
||||
assert_eq!(app.inputs["bindings.5.argument"], "my command");
|
||||
let _ = app.update(Message::Commit(schema::text("bindings.5.argument", "Argument", "", "")));
|
||||
let _ = app.update(Message::Commit(schema::text(
|
||||
"bindings.5.argument",
|
||||
"Argument",
|
||||
"",
|
||||
"",
|
||||
)));
|
||||
assert!(!app.visible_rows.contains(&("bindings", 5)));
|
||||
assert_eq!(app.draft.string("bindings.5.argument", ""), "my command");
|
||||
}
|
||||
@@ -1484,7 +1647,8 @@ mod tests {
|
||||
let registrations = (0..7)
|
||||
.map(|index| format!("personal-{index} {{ file \"unused.kdl\"; }}\n"))
|
||||
.collect::<String>();
|
||||
app.draft = Snapshot::parse(format!("theme {{ custom-themes {{ {registrations} }} }}")).unwrap();
|
||||
app.draft = Snapshot::parse(format!("theme {{ custom-themes {{ {registrations} }} }}"))
|
||||
.unwrap();
|
||||
let mut snapshot = app.resolved.clone();
|
||||
for index in 0..7 {
|
||||
let mut family = snapshot.families[0].clone();
|
||||
@@ -1503,7 +1667,10 @@ mod tests {
|
||||
2,
|
||||
true,
|
||||
));
|
||||
assert!(app.gallery_focus.is_some(), "built-in row must not claim custom focus");
|
||||
assert!(
|
||||
app.gallery_focus.is_some(),
|
||||
"built-in row must not claim custom focus"
|
||||
);
|
||||
let task = app.update(Message::RowVisibility(list, 2, true));
|
||||
assert!(task.units() > 0);
|
||||
assert!(app.gallery_focus.is_none());
|
||||
@@ -1536,7 +1703,9 @@ mod tests {
|
||||
for _ in 0..100 {
|
||||
let started = std::time::Instant::now();
|
||||
let view = app.page_view();
|
||||
std::hint::black_box(cosmic::iced::advanced::widget::Tree::new(view.as_widget()));
|
||||
std::hint::black_box(cosmic::iced::advanced::widget::Tree::new(
|
||||
view.as_widget(),
|
||||
));
|
||||
samples.push(started.elapsed());
|
||||
}
|
||||
samples.sort_unstable();
|
||||
@@ -1553,7 +1722,9 @@ mod tests {
|
||||
#[ignore = "release scroll/render sample; requires both renderer backends"]
|
||||
fn profile_dense_pages_on_both_renderers() {
|
||||
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree};
|
||||
use cosmic::iced::advanced::{
|
||||
Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree,
|
||||
};
|
||||
use cosmic::iced::{Color, Event, Font, Pixels, Point, Rectangle, Size};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -1589,9 +1760,11 @@ mod tests {
|
||||
let mut view = app.page_view();
|
||||
let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
tree.diff(view.as_widget_mut());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Mouse(mouse::Event::WheelScrolled {
|
||||
@@ -1626,7 +1799,12 @@ mod tests {
|
||||
let draw_elapsed = draw_started.elapsed();
|
||||
drop(view);
|
||||
let render_started = std::time::Instant::now();
|
||||
let pixels = Headless::screenshot(&mut renderer, size, scale, theme.cosmic().bg_color().into());
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
size,
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
std::hint::black_box(pixels);
|
||||
let render_elapsed = render_started.elapsed();
|
||||
|
||||
@@ -1643,7 +1821,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
for (stage, index) in [("layout", 0), ("draw_queue", 1), ("render_and_readback", 2)] {
|
||||
for (stage, index) in [("layout", 0), ("draw_queue", 1), ("render_and_readback", 2)]
|
||||
{
|
||||
let mut times = samples
|
||||
.iter()
|
||||
.map(|sample| match index {
|
||||
@@ -1722,7 +1901,8 @@ mod tests {
|
||||
let _ = app.update(Message::ExternalConfig(Ok(external.clone())));
|
||||
assert_eq!(app.current.source, external.source);
|
||||
assert_eq!(app.draft.number("animations.speed", 0.), 0.5);
|
||||
app.inputs.insert("theme.colors.accent".into(), "#ffffff".into());
|
||||
app.inputs
|
||||
.insert("theme.colors.accent".into(), "#ffffff".into());
|
||||
let newer = Snapshot::parse("animations {\n speed 0.8\n}\n".into()).unwrap();
|
||||
let _ = app.update(Message::ExternalConfig(Ok(newer)));
|
||||
assert_eq!(app.current.source, external.source);
|
||||
|
||||
@@ -2,7 +2,10 @@ use cosmic::iced::advanced::widget::Id;
|
||||
use cosmic::iced::advanced::widget::operation::{Operation, Scrollable};
|
||||
use cosmic::iced::{Rectangle, Vector};
|
||||
|
||||
pub(super) fn gallery_list(appearance: Option<ferese_config::theme::Appearance>, custom: bool) -> &'static str {
|
||||
pub(super) fn gallery_list(
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
custom: bool,
|
||||
) -> &'static str {
|
||||
match (appearance, custom) {
|
||||
(Some(ferese_config::theme::Appearance::Light), false) => "theme-light",
|
||||
(Some(ferese_config::theme::Appearance::Dark), false) => "theme-dark",
|
||||
@@ -28,7 +31,10 @@ impl RevealRow {
|
||||
}
|
||||
|
||||
pub(super) fn for_target(target: Id) -> Self {
|
||||
Self { target, bounds: None }
|
||||
Self {
|
||||
target,
|
||||
bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row, schema, scrollable,
|
||||
text_input, visuals, widget,
|
||||
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row,
|
||||
schema, scrollable, text_input, visuals, widget,
|
||||
};
|
||||
|
||||
pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.;
|
||||
|
||||
impl App {
|
||||
pub(super) fn page_view(&self) -> Element<'_, Message> {
|
||||
cosmic::iced::widget::responsive(move |size| self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
cosmic::iced::widget::responsive(move |size| {
|
||||
self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height)
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn page_layout(&self, compact: bool, height: f32) -> Element<'_, Message> {
|
||||
@@ -53,27 +55,36 @@ impl App {
|
||||
let mut navigation = column([]).spacing(16);
|
||||
for (title, pages) in Page::NAVIGATION {
|
||||
let mut section = column([]).spacing(3).push(
|
||||
container(self.label(title, 11.).class(cosmic::theme::Text::Color(palette.muted))).padding([0, 8]),
|
||||
container(
|
||||
self.label(title, 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
)
|
||||
.padding([0, 8]),
|
||||
);
|
||||
for &page in pages {
|
||||
let selected = self.page.navigation_page() == page;
|
||||
section = section.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::icon(
|
||||
page,
|
||||
if selected { palette.accent } else { palette.muted },
|
||||
))
|
||||
.push(self.label(page.title(), 13.)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([8, 8])
|
||||
.class(visuals::navigation_style(palette, selected))
|
||||
.name(page.title())
|
||||
.on_press(Message::Page(if selected { self.page } else { page })),
|
||||
);
|
||||
section =
|
||||
section.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::icon(
|
||||
page,
|
||||
if selected {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.push(self.label(page.title(), 13.)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([8, 8])
|
||||
.class(visuals::navigation_style(palette, selected))
|
||||
.name(page.title())
|
||||
.on_press(Message::Page(if selected { self.page } else { page })),
|
||||
);
|
||||
}
|
||||
navigation = navigation.push(section);
|
||||
}
|
||||
@@ -169,7 +180,8 @@ impl App {
|
||||
.flat_map(|(_, pages)| pages.iter().copied())
|
||||
};
|
||||
if !pages().any(|p| p.matches(&self.search)) {
|
||||
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
|
||||
body = body
|
||||
.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
|
||||
}
|
||||
for page in pages().filter(|p| p.matches(&self.search)) {
|
||||
let destination = page.search_destination(&self.search);
|
||||
@@ -188,13 +200,18 @@ impl App {
|
||||
}
|
||||
} else {
|
||||
if self.page == Page::Windows {
|
||||
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
|
||||
body = body.push(visuals::preview_resolved(
|
||||
&self.draft,
|
||||
&self.resolved.presented,
|
||||
));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
let split = visuals::split(&self.draft);
|
||||
if split {
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
body = body
|
||||
.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body =
|
||||
body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
} else {
|
||||
body = body.push(self.theme_gallery(None));
|
||||
}
|
||||
@@ -213,7 +230,9 @@ impl App {
|
||||
if self.page == Page::Wallpaper {
|
||||
body = body.push(self.wallpaper_controls());
|
||||
}
|
||||
if self.page == Page::Bar && self.panel_page != crate::panel_controls::PanelPage::Arrange {
|
||||
if self.page == Page::Bar
|
||||
&& self.panel_page != crate::panel_controls::PanelPage::Arrange
|
||||
{
|
||||
body = body.push(self.panel_controls());
|
||||
}
|
||||
let fields = if self.page == Page::Bar {
|
||||
@@ -269,7 +288,11 @@ impl App {
|
||||
group = group.push(self.theme_file_picker());
|
||||
}
|
||||
}
|
||||
body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.)));
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding(4)
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
}
|
||||
|
||||
match self.page {
|
||||
@@ -313,14 +336,21 @@ impl App {
|
||||
Kind::Toggle(true),
|
||||
)))
|
||||
.push(
|
||||
container(self.settings_icon_button(
|
||||
"Remove login item",
|
||||
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
|
||||
(!self.saving).then_some(Message::Remove("autostart".into(), index)),
|
||||
))
|
||||
container(
|
||||
self.settings_icon_button(
|
||||
"Remove login item",
|
||||
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
|
||||
(!self.saving)
|
||||
.then_some(Message::Remove("autostart".into(), index)),
|
||||
),
|
||||
)
|
||||
.padding([7, 10]),
|
||||
);
|
||||
body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.)));
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding(4)
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
}
|
||||
body = body.push(
|
||||
row([])
|
||||
@@ -333,12 +363,15 @@ impl App {
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(Message::NewCommand),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
"Add login item",
|
||||
"M12 5v14 M5 12h14",
|
||||
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
|
||||
false,
|
||||
)),
|
||||
.push(
|
||||
self.settings_button(
|
||||
"Add login item",
|
||||
"M12 5v14 M5 12h14",
|
||||
(!self.saving && !self.new_command.trim().is_empty())
|
||||
.then_some(Message::AddCommand),
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Page::Shortcuts => {
|
||||
@@ -373,36 +406,37 @@ impl App {
|
||||
body = body.push(gestures);
|
||||
|
||||
for index in 0..self.draft.records("bindings") {
|
||||
let content: Element<'static, Message> = if self.visible_rows.contains(&("bindings", index)) {
|
||||
let prefix = format!("bindings.{index}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.keys"),
|
||||
"Shortcut",
|
||||
"Keys or swipe, such as Super+Return",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.action"),
|
||||
"Action",
|
||||
"Action name, such as spawn or focus",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.argument"),
|
||||
"Argument",
|
||||
"Command, direction, or action argument",
|
||||
"",
|
||||
)));
|
||||
container(group)
|
||||
.padding(4)
|
||||
.height(164)
|
||||
.class(visuals::surface(palette.card, 14.))
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().height(164).width(Length::Fill).into()
|
||||
};
|
||||
let content: Element<'static, Message> =
|
||||
if self.visible_rows.contains(&("bindings", index)) {
|
||||
let prefix = format!("bindings.{index}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.keys"),
|
||||
"Shortcut",
|
||||
"Keys or swipe, such as Super+Return",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.action"),
|
||||
"Action",
|
||||
"Action name, such as spawn or focus",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.argument"),
|
||||
"Argument",
|
||||
"Command, direction, or action argument",
|
||||
"",
|
||||
)));
|
||||
container(group)
|
||||
.padding(4)
|
||||
.height(164)
|
||||
.class(visuals::surface(palette.card, 14.))
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().height(164).width(Length::Fill).into()
|
||||
};
|
||||
|
||||
body = body.push(
|
||||
cosmic::iced::widget::sensor(content)
|
||||
@@ -481,14 +515,17 @@ impl App {
|
||||
} else {
|
||||
content = content.push(
|
||||
scrollable(
|
||||
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 0.,
|
||||
right: 12.,
|
||||
bottom: 0.,
|
||||
left: 2.,
|
||||
})
|
||||
.width(Length::Fill),
|
||||
container(cosmic::iced::widget::keyed_column([(
|
||||
self.page as usize,
|
||||
body.into(),
|
||||
)]))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 0.,
|
||||
right: 12.,
|
||||
bottom: 0.,
|
||||
left: 2.,
|
||||
})
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.id(widget::Id::new("settings-content"))
|
||||
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
|
||||
@@ -511,7 +548,14 @@ impl App {
|
||||
},
|
||||
palette.muted,
|
||||
),
|
||||
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
|
||||
self.label(
|
||||
if self.saving {
|
||||
"Saving…"
|
||||
} else {
|
||||
&self.status
|
||||
},
|
||||
11.,
|
||||
),
|
||||
widget::tooltip::Position::Top,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
@@ -546,7 +590,11 @@ impl App {
|
||||
row([])
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.note(if self.saving { "Saving…" } else { &self.status }))
|
||||
.push(self.note(if self.saving {
|
||||
"Saving…"
|
||||
} else {
|
||||
&self.status
|
||||
}))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
.push(self.settings_button(
|
||||
"Undo",
|
||||
@@ -577,16 +625,21 @@ impl App {
|
||||
};
|
||||
let view = row([]).push(sidebar).push(
|
||||
container(
|
||||
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 20.,
|
||||
right: 20.,
|
||||
bottom: 8.,
|
||||
left: 20.,
|
||||
})
|
||||
.max_width(if self.page == Page::Bar { 1120 } else { 840 })
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
container(
|
||||
column([])
|
||||
.push(content.height(Length::Fill))
|
||||
.push(footer)
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 20.,
|
||||
right: 20.,
|
||||
bottom: 8.,
|
||||
left: 20.,
|
||||
})
|
||||
.max_width(if self.page == Page::Bar { 1120 } else { 840 })
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
@@ -598,13 +651,28 @@ impl App {
|
||||
let mut view = widget::popover(view).modal(true);
|
||||
if let Some(sidebar) = drawer {
|
||||
let dismiss = widget::mouse_area(
|
||||
container(widget::Space::new().width(Length::Fill).height(Length::Fill))
|
||||
.class(visuals::surface(cosmic::iced::Color::BLACK.scale_alpha(0.20), 0.)),
|
||||
container(
|
||||
widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.class(visuals::surface(
|
||||
cosmic::iced::Color::BLACK.scale_alpha(0.20),
|
||||
0.,
|
||||
)),
|
||||
)
|
||||
.on_press(Message::Sidebar(false));
|
||||
view = view
|
||||
.position(widget::popover::Position::Point(cosmic::iced::Point::ORIGIN))
|
||||
.popup(row([]).push(sidebar).push(dismiss).width(Length::Fill).height(height));
|
||||
.position(widget::popover::Position::Point(
|
||||
cosmic::iced::Point::ORIGIN,
|
||||
))
|
||||
.popup(
|
||||
row([])
|
||||
.push(sidebar)
|
||||
.push(dismiss)
|
||||
.width(Length::Fill)
|
||||
.height(height),
|
||||
);
|
||||
}
|
||||
let view: Element<'_, Message> = view.into();
|
||||
if self.profile_pages {
|
||||
|
||||
@@ -69,59 +69,68 @@ impl App {
|
||||
let mut rows = widget::column([]).spacing(12);
|
||||
match self.panel_page {
|
||||
PanelPage::Panels => {
|
||||
rows = rows.push(
|
||||
widget::container(
|
||||
widget::row([])
|
||||
.spacing(12)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.push(crate::visuals::action_icon(
|
||||
if edge == Edge::Top {
|
||||
"M3 5h18v14H3z M3 9h18"
|
||||
} else {
|
||||
"M3 5h18v14H3z M3 15h18"
|
||||
},
|
||||
palette.accent,
|
||||
))
|
||||
.push(
|
||||
widget::column([])
|
||||
.spacing(3)
|
||||
.push(self.panel_heading(
|
||||
if edge == Edge::Top { "Top panel" } else { "Bottom panel" },
|
||||
14.,
|
||||
))
|
||||
.push(self.note("Shown on all displays"))
|
||||
.width(cosmic::iced::Length::Fill),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
"Edit panel",
|
||||
"M4 20l4-1 12-12-3-3L5 16z",
|
||||
Some(Message::PanelPage(PanelPage::Arrange)),
|
||||
false,
|
||||
))
|
||||
.push(
|
||||
self.settings_icon_button(
|
||||
rows =
|
||||
rows.push(
|
||||
widget::container(
|
||||
widget::row([])
|
||||
.spacing(12)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.push(crate::visuals::action_icon(
|
||||
if edge == Edge::Top {
|
||||
"M3 5h18v14H3z M3 9h18"
|
||||
} else {
|
||||
"M3 5h18v14H3z M3 15h18"
|
||||
},
|
||||
palette.accent,
|
||||
))
|
||||
.push(
|
||||
widget::column([])
|
||||
.spacing(3)
|
||||
.push(self.panel_heading(
|
||||
if edge == Edge::Top {
|
||||
"Top panel"
|
||||
} else {
|
||||
"Bottom panel"
|
||||
},
|
||||
14.,
|
||||
))
|
||||
.push(self.note("Shown on all displays"))
|
||||
.width(cosmic::iced::Length::Fill),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
"Edit panel",
|
||||
"M4 20l4-1 12-12-3-3L5 16z",
|
||||
Some(Message::PanelPage(PanelPage::Arrange)),
|
||||
false,
|
||||
))
|
||||
.push(self.settings_icon_button(
|
||||
"Remove custom panel",
|
||||
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5",
|
||||
self.draft
|
||||
.item("panels")
|
||||
.is_some()
|
||||
.then(|| Message::Change(store::Edit::Unset("panels".into()))),
|
||||
),
|
||||
),
|
||||
)
|
||||
.padding(14)
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
self.draft.item("panels").is_some().then(|| {
|
||||
Message::Change(store::Edit::Unset("panels".into()))
|
||||
}),
|
||||
)),
|
||||
)
|
||||
.padding(14)
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
let preset = self.preview_panel().ok().and_then(|panel| panel.preset());
|
||||
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
|
||||
let label = if position == Edge::Top { "Top" } else { "Bottom" };
|
||||
let label = if position == Edge::Top {
|
||||
"Top"
|
||||
} else {
|
||||
"Bottom"
|
||||
};
|
||||
widget::button::custom(self.label(label, 12.))
|
||||
.name(format!("Panel position: {label}"))
|
||||
.padding([8, 10])
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, edge == position))
|
||||
.on_press_maybe((edge != position).then_some(Message::PanelEdit(Action::SetEdge(position))))
|
||||
.on_press_maybe(
|
||||
(edge != position)
|
||||
.then_some(Message::PanelEdit(Action::SetEdge(position))),
|
||||
)
|
||||
.into()
|
||||
}))
|
||||
.spacing(2);
|
||||
@@ -151,11 +160,14 @@ impl App {
|
||||
"Outline",
|
||||
"Add a fine line around the panel shape.",
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette)
|
||||
.name("Outline")
|
||||
.on_press(Message::PanelEdit(Action::SetBorder(
|
||||
!self.draft.boolean("panels.0.border", false),
|
||||
))),
|
||||
ferese_theme::controls::switch(
|
||||
self.draft.boolean("panels.0.border", false),
|
||||
palette,
|
||||
)
|
||||
.name("Outline")
|
||||
.on_press(Message::PanelEdit(Action::SetBorder(
|
||||
!self.draft.boolean("panels.0.border", false),
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -179,7 +191,9 @@ impl App {
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(attached, palette)
|
||||
.name("Attach to screen edge")
|
||||
.on_press(Message::PanelEdit(Action::SetMorphAttached(!attached))),
|
||||
.on_press(Message::PanelEdit(Action::SetMorphAttached(
|
||||
!attached,
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -193,7 +207,9 @@ impl App {
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name("Preview on hover")
|
||||
.on_press(Message::PanelEdit(Action::SetPeekOnHover(!enabled))),
|
||||
.on_press(Message::PanelEdit(Action::SetPeekOnHover(
|
||||
!enabled,
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -206,10 +222,9 @@ impl App {
|
||||
.padding([8, 10])
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, count == maximum))
|
||||
.on_press_maybe(
|
||||
(count != maximum)
|
||||
.then(|| Message::Change(store::set("activities.max_visible", count))),
|
||||
)
|
||||
.on_press_maybe((count != maximum).then(|| {
|
||||
Message::Change(store::set("activities.max_visible", count))
|
||||
}))
|
||||
.into()
|
||||
}))
|
||||
.spacing(2);
|
||||
@@ -232,7 +247,10 @@ impl App {
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(show_badge, palette)
|
||||
.name("Show activity count")
|
||||
.on_press(Message::Change(store::set("activities.show_badge", !show_badge))),
|
||||
.on_press(Message::Change(store::set(
|
||||
"activities.show_badge",
|
||||
!show_badge,
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -246,7 +264,8 @@ impl App {
|
||||
.class(crate::visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
let pins = self.preview_panel().is_ok_and(|panel| {
|
||||
panel.geometry != ferese_panel::GeometryIntent::default() || panel.corner_radius.is_some()
|
||||
panel.geometry != ferese_panel::GeometryIntent::default()
|
||||
|| panel.corner_radius.is_some()
|
||||
});
|
||||
let mut settings = widget::column([]).spacing(4).push(
|
||||
widget::container(
|
||||
@@ -277,12 +296,18 @@ impl App {
|
||||
!matches!(
|
||||
preset.unwrap_or_default(),
|
||||
ferese_panel::PanelStyle::Flat | ferese_panel::PanelStyle::Notch
|
||||
) || !matches!(field.path.rsplit('.').next(), Some("edge_margin" | "side_margins"))
|
||||
) || !matches!(
|
||||
field.path.rsplit('.').next(),
|
||||
Some("edge_margin" | "side_margins")
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut field = field;
|
||||
if preset == Some(ferese_panel::PanelStyle::Morph) {
|
||||
if matches!(field.path.rsplit('.').next(), Some("inner_padding" | "side_margins")) {
|
||||
if matches!(
|
||||
field.path.rsplit('.').next(),
|
||||
Some("inner_padding" | "side_margins")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if field.path.ends_with(".height") {
|
||||
@@ -296,15 +321,23 @@ impl App {
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap();
|
||||
*default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap();
|
||||
*default = geometry[field.path.rsplit('.').next().unwrap()]
|
||||
.as_f64()
|
||||
.unwrap();
|
||||
}
|
||||
let pinned = self.draft.item(&field.path).is_some();
|
||||
let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(field.path.clone())));
|
||||
let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(
|
||||
field.path.clone(),
|
||||
)));
|
||||
let reset_label = format!("Reset {}", field.label);
|
||||
settings = settings.push(
|
||||
widget::row![
|
||||
self.field(field),
|
||||
self.settings_icon_button(&reset_label, "M3 12a9 9 0 1 0 3-6 M3 3v6h6", reset)
|
||||
self.settings_icon_button(
|
||||
&reset_label,
|
||||
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
|
||||
reset
|
||||
)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center),
|
||||
@@ -381,7 +414,10 @@ impl App {
|
||||
|
||||
fn panel_opacity_controls(&self) -> Element<'static, Message> {
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
if self.draft.boolean("theme.accessibility.reduce_transparency", false) {
|
||||
if self
|
||||
.draft
|
||||
.boolean("theme.accessibility.reduce_transparency", false)
|
||||
{
|
||||
return self.panel_appearance_row(
|
||||
"Background opacity",
|
||||
"Reduce transparency is on. Turn it off in Accessibility to change opacity.",
|
||||
@@ -424,16 +460,21 @@ impl App {
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
crate::slider(0. ..=100., value, move |value| Message::Range(field.clone(), value))
|
||||
.step(1.)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(Length::Fill),
|
||||
crate::slider(0. ..=100., value, move |value| {
|
||||
Message::Range(field.clone(), value)
|
||||
})
|
||||
.step(1.)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
widget::container(self.label(format!("{value:.0}%"), 12.).align_x(Alignment::Center))
|
||||
.padding([6, 0])
|
||||
.width(48),
|
||||
widget::container(
|
||||
self.label(format!("{value:.0}%"), 12.)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding([6, 0])
|
||||
.width(48),
|
||||
)
|
||||
.push(self.settings_icon_button(
|
||||
"Use shell opacity",
|
||||
@@ -458,26 +499,30 @@ impl App {
|
||||
.preview_panel()
|
||||
.map(|panel| panel.resolved_geometry().height * 0.5)
|
||||
.unwrap_or(18.);
|
||||
let custom = self
|
||||
.draft
|
||||
.item("panels.0.corner_radius")
|
||||
.and_then(|value| serde_json::from_value::<ferese_panel::CornerRadii>(value.clone()).ok());
|
||||
let custom = self.draft.item("panels.0.corner_radius").and_then(|value| {
|
||||
serde_json::from_value::<ferese_panel::CornerRadii>(value.clone()).ok()
|
||||
});
|
||||
let radii = custom.unwrap_or_else(|| ferese_panel::CornerRadii::uniform(shell_radius));
|
||||
let modes = widget::row([("Automatic", false), ("Custom", true)].map(|(label, selected)| {
|
||||
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
|
||||
.name(format!("Corner radius: {label}"))
|
||||
.padding([8, 12])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, custom.is_some() == selected))
|
||||
.on_press_maybe((custom.is_some() != selected).then(|| {
|
||||
Message::PanelEdit(if selected {
|
||||
Action::SetRadius(format!("{shell_radius}px"))
|
||||
} else {
|
||||
Action::ClearRadius
|
||||
})
|
||||
}))
|
||||
.into()
|
||||
}))
|
||||
let modes = widget::row([("Automatic", false), ("Custom", true)].map(
|
||||
|(label, selected)| {
|
||||
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
|
||||
.name(format!("Corner radius: {label}"))
|
||||
.padding([8, 12])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(
|
||||
palette,
|
||||
custom.is_some() == selected,
|
||||
))
|
||||
.on_press_maybe((custom.is_some() != selected).then(|| {
|
||||
Message::PanelEdit(if selected {
|
||||
Action::SetRadius(format!("{shell_radius}px"))
|
||||
} else {
|
||||
Action::ClearRadius
|
||||
})
|
||||
}))
|
||||
.into()
|
||||
},
|
||||
))
|
||||
.spacing(2);
|
||||
let modes = widget::container(modes)
|
||||
.padding(3)
|
||||
@@ -491,7 +536,11 @@ impl App {
|
||||
(2, "Outer right", "M5 19h10a4 4 0 0 0 4-4V5"),
|
||||
] {
|
||||
let value = radii.0[index];
|
||||
let tint = if custom.is_some() { palette.text } else { palette.muted };
|
||||
let tint = if custom.is_some() {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
};
|
||||
corners.push(
|
||||
widget::container(
|
||||
widget::row([])
|
||||
@@ -499,14 +548,13 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.push(crate::visuals::action_icon(icon, palette.muted))
|
||||
.push(self.label(label, 12.).width(Length::Fill))
|
||||
.push(
|
||||
self.settings_icon_button(
|
||||
&format!("Decrease {label} radius"),
|
||||
"M5 12h14",
|
||||
(custom.is_some() && value > 0.)
|
||||
.then_some(Message::PanelEdit(Action::SetCorner(index, (value - 1.).max(0.)))),
|
||||
),
|
||||
)
|
||||
.push(self.settings_icon_button(
|
||||
&format!("Decrease {label} radius"),
|
||||
"M5 12h14",
|
||||
(custom.is_some() && value > 0.).then_some(Message::PanelEdit(
|
||||
Action::SetCorner(index, (value - 1.).max(0.)),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
self.label(format!("{value} px"), 12.)
|
||||
.width(52)
|
||||
@@ -519,7 +567,10 @@ impl App {
|
||||
"M12 5v14 M5 12h14",
|
||||
custom
|
||||
.is_some()
|
||||
.then_some(Message::PanelEdit(Action::SetCorner(index, value + 1.))),
|
||||
.then_some(Message::PanelEdit(Action::SetCorner(
|
||||
index,
|
||||
value + 1.,
|
||||
))),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -637,7 +688,10 @@ impl App {
|
||||
zone: placement.key(),
|
||||
group: group.id.clone(),
|
||||
},
|
||||
format!("panels.0.{}.groups.{group_index}.items.{item_index}", placement.key()),
|
||||
format!(
|
||||
"panels.0.{}.groups.{group_index}.items.{item_index}",
|
||||
placement.key()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -704,14 +758,20 @@ impl App {
|
||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut zones = widget::column([]).spacing(18);
|
||||
if panel.group_count() == ferese_panel::MAX_GROUPS {
|
||||
zones = zones.push(self.label(format!("{}-group limit reached", ferese_panel::MAX_GROUPS), 12.));
|
||||
zones = zones.push(self.label(
|
||||
format!("{}-group limit reached", ferese_panel::MAX_GROUPS),
|
||||
12.,
|
||||
));
|
||||
}
|
||||
for placement in Zone::ALL {
|
||||
let zone = placement.definition(panel);
|
||||
let mut groups = widget::column([]).spacing(8).push(
|
||||
widget::row([])
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.panel_heading(placement.to_string(), 13.).width(Length::Fill))
|
||||
.push(
|
||||
self.panel_heading(placement.to_string(), 13.)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
self.settings_button(
|
||||
"Add group",
|
||||
@@ -733,7 +793,10 @@ impl App {
|
||||
widget::row([])
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.push(crate::visuals::action_icon("M3 7h18v13H3z M3 7V4h7l3 3", palette.muted))
|
||||
.push(crate::visuals::action_icon(
|
||||
"M3 7h18v13H3z M3 7V4h7l3 3",
|
||||
palette.muted,
|
||||
))
|
||||
.push(self.label(group_name(&group.id.0), 13.)),
|
||||
)
|
||||
.name(format!("Edit group {}", group.id.0))
|
||||
@@ -746,9 +809,11 @@ impl App {
|
||||
.on_press(Message::PanelGroupSelect(group.id.clone())),
|
||||
);
|
||||
let add_item = crate::visuals::panel_select(
|
||||
cosmic::iced::widget::pick_list(CATALOG.to_vec(), None::<CatalogItem>, move |kind| {
|
||||
Message::PanelEdit(Action::Add(kind.0, destination.clone()))
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
CATALOG.to_vec(),
|
||||
None::<CatalogItem>,
|
||||
move |kind| Message::PanelEdit(Action::Add(kind.0, destination.clone())),
|
||||
)
|
||||
.placeholder("Add item")
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
@@ -776,13 +841,15 @@ impl App {
|
||||
palette.muted.scale_alpha(0.6)
|
||||
},
|
||||
))
|
||||
.push(self.label(item.kind.label(), 13.).width(Length::Fill).class(
|
||||
cosmic::theme::Text::Color(if item.visible {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted.scale_alpha(0.6)
|
||||
}),
|
||||
)),
|
||||
.push(
|
||||
self.label(item.kind.label(), 13.)
|
||||
.width(Length::Fill)
|
||||
.class(cosmic::theme::Text::Color(if item.visible {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted.scale_alpha(0.6)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.name(format!("Edit {} · {}", item.kind.label(), item.id.0))
|
||||
.padding([8, 6])
|
||||
@@ -796,7 +863,10 @@ impl App {
|
||||
widget::container(
|
||||
widget::row([])
|
||||
.align_y(Alignment::Center)
|
||||
.push(widget::mouse_area(grip).interaction(cosmic::iced::mouse::Interaction::Grab))
|
||||
.push(
|
||||
widget::mouse_area(grip)
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab),
|
||||
)
|
||||
.push(select),
|
||||
)
|
||||
.id(crate::panel_drag::item_id(&item.id))
|
||||
@@ -806,7 +876,8 @@ impl App {
|
||||
}
|
||||
let mut contents = widget::column([]).spacing(4).push(heading);
|
||||
if items.is_empty() {
|
||||
contents = contents.push(widget::container(self.note("Drop items here")).padding([8, 6]));
|
||||
contents = contents
|
||||
.push(widget::container(self.note("Drop items here")).padding([8, 6]));
|
||||
} else {
|
||||
contents = contents.push(
|
||||
widget::flex_row(items)
|
||||
@@ -854,7 +925,11 @@ impl App {
|
||||
.class(crate::visuals::surface(palette.card, 10.)),
|
||||
);
|
||||
}
|
||||
zones = zones.push(widget::container(groups).padding([0, 4]).width(Length::Fill));
|
||||
zones = zones.push(
|
||||
widget::container(groups)
|
||||
.padding([0, 4])
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
crate::panel_drag::frame(zones.into(), panel, palette.accent)
|
||||
}
|
||||
@@ -899,7 +974,11 @@ mod tests {
|
||||
use cosmic::Application;
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
("/unused/range.kdl".into(), store::Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/unused/range.kdl".into(),
|
||||
store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
assert_eq!(app.panel_side_margin_limit(), None);
|
||||
@@ -928,19 +1007,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn customization_uses_canonical_defaults_and_retains_other_fields() {
|
||||
let mut snapshot = store::Snapshot::parse("// keep\nanimations { speed 0.8; }\n".into()).unwrap();
|
||||
let mut snapshot =
|
||||
store::Snapshot::parse("// keep\nanimations { speed 0.8; }\n".into()).unwrap();
|
||||
snapshot.edit(&initialize(&snapshot).unwrap()).unwrap();
|
||||
let snapshot = store::Snapshot::parse(snapshot.source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Flat));
|
||||
assert_eq!(panels[0].resolved_geometry(), ferese_panel::PanelGeometry::default());
|
||||
assert_eq!(
|
||||
panels[0].resolved_geometry(),
|
||||
ferese_panel::PanelGeometry::default()
|
||||
);
|
||||
assert!(panels[0].center.groups[0].items[0].visible);
|
||||
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
|
||||
assert!(snapshot.source.contains("// keep"));
|
||||
let id = snapshot.string("panels.0.end.groups.0.items.2.id", "");
|
||||
let mut changed = snapshot;
|
||||
changed
|
||||
.edit(&store::set("panels.0.end.groups.0.items.2.overflow", "always"))
|
||||
.edit(&store::set(
|
||||
"panels.0.end.groups.0.items.2.overflow",
|
||||
"always",
|
||||
))
|
||||
.unwrap();
|
||||
let changed = store::Snapshot::parse(changed.source).unwrap();
|
||||
assert_eq!(changed.string("panels.0.end.groups.0.items.2.id", ""), id);
|
||||
@@ -978,7 +1065,11 @@ mod tests {
|
||||
.unwrap();
|
||||
let app = App::init(
|
||||
crate::Core::default(),
|
||||
("/unused/editor.kdl".into(), store::Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/unused/editor.kdl".into(),
|
||||
store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
let panel = Panel::default();
|
||||
|
||||
@@ -27,7 +27,11 @@ enum Region {
|
||||
Empty(Zone),
|
||||
}
|
||||
|
||||
pub(super) fn frame<'a>(content: Element<'a, Message>, panel: &Panel, accent: Color) -> Element<'a, Message> {
|
||||
pub(super) fn frame<'a>(
|
||||
content: Element<'a, Message>,
|
||||
panel: &Panel,
|
||||
accent: Color,
|
||||
) -> Element<'a, Message> {
|
||||
let mut regions = Vec::new();
|
||||
for zone in Zone::ALL {
|
||||
if zone.definition(panel).groups.is_empty() {
|
||||
@@ -40,7 +44,10 @@ pub(super) fn frame<'a>(content: Element<'a, Message>, panel: &Panel, accent: Co
|
||||
};
|
||||
regions.push((group_id(&group.id), Region::Group(destination.clone())));
|
||||
for item in &group.items {
|
||||
regions.push((item_id(&item.id), Region::Item(item.id.clone(), destination.clone())));
|
||||
regions.push((
|
||||
item_id(&item.id),
|
||||
Region::Item(item.id.clone(), destination.clone()),
|
||||
));
|
||||
regions.push((grip_id(&item.id), Region::Grip(item.id.clone())));
|
||||
}
|
||||
}
|
||||
@@ -84,7 +91,9 @@ impl Target {
|
||||
fn action(self, item: ItemId) -> Action {
|
||||
match self {
|
||||
Self::Group {
|
||||
destination, before, ..
|
||||
destination,
|
||||
before,
|
||||
..
|
||||
} => Action::Place(item, destination, before),
|
||||
Self::Empty(zone, _) => Action::PlaceInZone(item, zone),
|
||||
}
|
||||
@@ -111,7 +120,11 @@ impl widget::Operation for Bounds<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_at(regions: &[(Region, Rectangle)], cursor: Point, viewport: &Rectangle) -> Option<Target> {
|
||||
fn target_at(
|
||||
regions: &[(Region, Rectangle)],
|
||||
cursor: Point,
|
||||
viewport: &Rectangle,
|
||||
) -> Option<Target> {
|
||||
if !viewport.contains(cursor) {
|
||||
return None;
|
||||
}
|
||||
@@ -125,12 +138,15 @@ fn target_at(regions: &[(Region, Rectangle)], cursor: Point, viewport: &Rectangl
|
||||
let items: Vec<_> = regions
|
||||
.iter()
|
||||
.filter_map(|(region, bounds)| match region {
|
||||
Region::Item(id, group) if group.group == destination.group => Some((id, *bounds)),
|
||||
Region::Item(id, group) if group.group == destination.group => {
|
||||
Some((id, *bounds))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let next = items.iter().find(|(_, item)| {
|
||||
cursor.y < item.y || cursor.y <= item.y + item.height && cursor.x < item.center_x()
|
||||
cursor.y < item.y
|
||||
|| cursor.y <= item.y + item.height && cursor.x < item.center_x()
|
||||
});
|
||||
let (before, marker) = next.map_or((None, *bounds), |(id, item)| {
|
||||
(
|
||||
@@ -171,13 +187,24 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut widget::Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
|
||||
if tree.state.downcast_ref::<State>().drag.as_ref().is_some_and(|drag| {
|
||||
!self
|
||||
.regions
|
||||
.iter()
|
||||
.any(|(_, region)| matches!(region, Region::Grip(id) if id == &drag.item))
|
||||
}) {
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
if tree
|
||||
.state
|
||||
.downcast_ref::<State>()
|
||||
.drag
|
||||
.as_ref()
|
||||
.is_some_and(|drag| {
|
||||
!self
|
||||
.regions
|
||||
.iter()
|
||||
.any(|(_, region)| matches!(region, Region::Grip(id) if id == &drag.item))
|
||||
})
|
||||
{
|
||||
tree.state.downcast_mut::<State>().drag = None;
|
||||
}
|
||||
self.content
|
||||
@@ -220,10 +247,10 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let Some(cursor) = cursor.position().filter(|point| viewport.contains(*point))
|
||||
&& let Some((Region::Grip(item), _)) = bounds
|
||||
.found
|
||||
.iter()
|
||||
.find(|(region, bounds)| matches!(region, Region::Grip(_)) && bounds.contains(cursor))
|
||||
&& let Some((Region::Grip(item), _)) =
|
||||
bounds.found.iter().find(|(region, bounds)| {
|
||||
matches!(region, Region::Grip(_)) && bounds.contains(cursor)
|
||||
})
|
||||
{
|
||||
let source = bounds
|
||||
.found
|
||||
@@ -261,7 +288,9 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.drag.is_some() => {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
if state.drag.is_some() =>
|
||||
{
|
||||
let drag = state.drag.take().unwrap();
|
||||
if drag.moved {
|
||||
if let Some(target) = cursor
|
||||
@@ -304,9 +333,15 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
use cosmic::iced::advanced::Renderer as _;
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
if let Some(drag) = tree
|
||||
.state
|
||||
.downcast_ref::<State>()
|
||||
@@ -327,7 +362,10 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Color { a: 0.12, ..self.accent },
|
||||
Color {
|
||||
a: 0.12,
|
||||
..self.accent
|
||||
},
|
||||
);
|
||||
}
|
||||
let ghost = Rectangle {
|
||||
@@ -346,7 +384,10 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Color { a: 0.12, ..self.accent },
|
||||
Color {
|
||||
a: 0.12,
|
||||
..self.accent
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -362,9 +403,13 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
if tree.state.downcast_ref::<State>().drag.is_some() {
|
||||
mouse::Interaction::Grabbing
|
||||
} else {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn a11y_nodes(
|
||||
@@ -373,7 +418,9 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
@@ -394,9 +441,13 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +469,12 @@ mod tests {
|
||||
let items = group.items.iter().map(|item| {
|
||||
container(
|
||||
row([])
|
||||
.push(container(text("::")).width(20).height(32).id(grip_id(&item.id)))
|
||||
.push(
|
||||
container(text("::"))
|
||||
.width(20)
|
||||
.height(32)
|
||||
.id(grip_id(&item.id)),
|
||||
)
|
||||
.push(
|
||||
button::custom(text(item.id.0.clone()))
|
||||
.width(60)
|
||||
@@ -489,12 +545,19 @@ mod tests {
|
||||
let mut view = view(&panel);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let viewport = Rectangle::with_size(Size::new(500., 200.));
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||
snapshot.edit(&set("panels", serde_json::json!([panel]))).unwrap();
|
||||
for (destination, empty) in [(Point::new(332., 20.), false), (Point::new(180., 20.), true)] {
|
||||
snapshot
|
||||
.edit(&set("panels", serde_json::json!([panel])))
|
||||
.unwrap();
|
||||
for (destination, empty) in [
|
||||
(Point::new(332., 20.), false),
|
||||
(Point::new(180., 20.), true),
|
||||
] {
|
||||
let mut messages = Vec::new();
|
||||
send(
|
||||
&mut view,
|
||||
@@ -510,7 +573,9 @@ mod tests {
|
||||
&mut tree,
|
||||
&node,
|
||||
&renderer,
|
||||
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: destination,
|
||||
}),
|
||||
destination,
|
||||
&mut messages,
|
||||
);
|
||||
@@ -550,9 +615,11 @@ mod tests {
|
||||
let mut view = view(&panel);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let viewport = Rectangle::with_size(Size::new(500., 200.));
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
for cancel in [true, false] {
|
||||
let mut messages = Vec::new();
|
||||
let outside = Point::new(600., 20.);
|
||||
@@ -624,7 +691,9 @@ mod tests {
|
||||
&mut tree,
|
||||
&node,
|
||||
&renderer,
|
||||
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: destination,
|
||||
}),
|
||||
destination,
|
||||
&mut messages,
|
||||
);
|
||||
@@ -646,7 +715,10 @@ mod tests {
|
||||
destination,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(messages.is_empty(), "cancellation must not save a valid drop");
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"cancellation must not save a valid drop"
|
||||
);
|
||||
}
|
||||
for point in [Point::new(12., 20.), Point::new(60., 20.)] {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
@@ -111,21 +111,28 @@ pub(super) fn group_record_path(panel: &Panel, id: &GroupId) -> Result<String, S
|
||||
}
|
||||
|
||||
pub(super) fn destinations(panel: &Panel) -> Vec<Destination> {
|
||||
[("start", &panel.start), ("center", &panel.center), ("end", &panel.end)]
|
||||
.into_iter()
|
||||
.flat_map(|(zone, definition)| {
|
||||
definition.groups.iter().map(move |group| Destination {
|
||||
zone,
|
||||
group: group.id.clone(),
|
||||
})
|
||||
[
|
||||
("start", &panel.start),
|
||||
("center", &panel.center),
|
||||
("end", &panel.end),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(zone, definition)| {
|
||||
definition.groups.iter().map(move |group| Destination {
|
||||
zone,
|
||||
group: group.id.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_path(panel: &Panel, destination: &Destination) -> Result<String, String> {
|
||||
// The zone is a display label; a queued selection follows the group's ID
|
||||
// even if another edit has since moved it to a different zone.
|
||||
Ok(format!("{}.items", group_record_path(panel, &destination.group)?))
|
||||
Ok(format!(
|
||||
"{}.items",
|
||||
group_record_path(panel, &destination.group)?
|
||||
))
|
||||
}
|
||||
|
||||
fn locate(panel: &Panel, id: &ItemId) -> Result<(String, usize), String> {
|
||||
@@ -136,7 +143,11 @@ fn locate(panel: &Panel, id: &ItemId) -> Result<(String, usize), String> {
|
||||
"center" => &panel.center,
|
||||
_ => &panel.end,
|
||||
};
|
||||
let group = zone.groups.iter().find(|group| group.id == destination.group).unwrap();
|
||||
let group = zone
|
||||
.groups
|
||||
.iter()
|
||||
.find(|group| group.id == destination.group)
|
||||
.unwrap();
|
||||
if let Some(index) = group.items.iter().position(|item| &item.id == id) {
|
||||
return Ok((path, index));
|
||||
}
|
||||
@@ -168,8 +179,13 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
edits.extend(plan(&candidate, action)?);
|
||||
return Ok(edits);
|
||||
}
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").ok_or("Customize items first.")?.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let panels: Vec<Panel> = serde_json::from_value(
|
||||
snapshot
|
||||
.item("panels")
|
||||
.ok_or("Customize items first.")?
|
||||
.clone(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
ferese_panel::validate(&panels)?;
|
||||
let panel = &panels[0];
|
||||
let earlier = matches!(&action, Action::Earlier(_));
|
||||
@@ -227,7 +243,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
} else {
|
||||
(index + 1 < items.len()).then_some(index + 1)
|
||||
};
|
||||
let Some(next) = next else { return Ok(Vec::new()) };
|
||||
let Some(next) = next else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
items.swap(index, next);
|
||||
vec![set(&path, items)]
|
||||
}
|
||||
@@ -318,7 +336,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
return Err("Unknown panel item setting.".into());
|
||||
}
|
||||
let (path, index) = locate(panel, &id)?;
|
||||
if field == "style" && !matches!(panel.item(&id).unwrap().kind, ItemKind::Workspaces { .. }) {
|
||||
if field == "style"
|
||||
&& !matches!(panel.item(&id).unwrap().kind, ItemKind::Workspaces { .. })
|
||||
{
|
||||
return Err("Workspace style belongs to Workspaces items.".into());
|
||||
}
|
||||
vec![set(&format!("{path}.{index}.{field}"), value)]
|
||||
@@ -348,7 +368,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
.find(|id| panel.item(&ItemId(id.clone())).is_none())
|
||||
.unwrap();
|
||||
let mut items = records(snapshot, &path);
|
||||
items.push(serde_json::to_value(Item::new(&id, kind)).map_err(|error| error.to_string())?);
|
||||
items.push(
|
||||
serde_json::to_value(Item::new(&id, kind)).map_err(|error| error.to_string())?,
|
||||
);
|
||||
vec![set(&path, items)]
|
||||
}
|
||||
Action::AddGroup(zone) => {
|
||||
@@ -389,7 +411,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
} else {
|
||||
(index + 1 < groups.len()).then_some(index + 1)
|
||||
};
|
||||
let Some(next) = next else { return Ok(Vec::new()) };
|
||||
let Some(next) = next else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
groups.swap(index, next);
|
||||
vec![set(&path, groups)]
|
||||
}
|
||||
@@ -398,13 +422,17 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
if !zone.definition(panel).groups[index].items.is_empty() {
|
||||
return Err("Move or remove this group's items before removing the group.".into());
|
||||
}
|
||||
vec![Edit::Remove(format!("panels.0.{}.groups", zone.key()), index)]
|
||||
vec![Edit::Remove(
|
||||
format!("panels.0.{}.groups", zone.key()),
|
||||
index,
|
||||
)]
|
||||
}
|
||||
Action::SetGroup(id, field, value) => {
|
||||
let (field, value) = match field.as_str() {
|
||||
"padding_vertical" | "padding_horizontal" => {
|
||||
let (zone, index) = locate_group(panel, &id)?;
|
||||
let mut padding = serde_json::to_value(zone.definition(panel).groups[index].padding).unwrap();
|
||||
let mut padding =
|
||||
serde_json::to_value(zone.definition(panel).groups[index].padding).unwrap();
|
||||
padding[usize::from(field == "padding_horizontal")] = value;
|
||||
("padding".to_owned(), padding)
|
||||
}
|
||||
@@ -413,7 +441,10 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
if !matches!(field.as_str(), "spacing" | "padding" | "island_padding") {
|
||||
return Err("Unknown panel group setting.".into());
|
||||
}
|
||||
vec![set(&format!("{}.{field}", group_record_path(panel, &id)?), value)]
|
||||
vec![set(
|
||||
&format!("{}.{field}", group_record_path(panel, &id)?),
|
||||
value,
|
||||
)]
|
||||
}
|
||||
};
|
||||
let mut candidate = snapshot.clone();
|
||||
@@ -431,12 +462,21 @@ mod tests {
|
||||
#[test]
|
||||
fn first_geometry_edit_materializes_the_panel_atomically() {
|
||||
let mut snapshot = Snapshot::parse("// keep\nanimations { speed 0.8; }".into()).unwrap();
|
||||
let edits = plan(&snapshot, Action::SetGeometry("side_margins".into(), 18.into())).unwrap();
|
||||
assert_eq!(edits.len(), 2, "initialize and edit share one save/undo transaction");
|
||||
let edits = plan(
|
||||
&snapshot,
|
||||
Action::SetGeometry("side_margins".into(), 18.into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edits.len(),
|
||||
2,
|
||||
"initialize and edit share one save/undo transaction"
|
||||
);
|
||||
for edit in edits {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].resolved_geometry().height, 36.);
|
||||
assert_eq!(panels[0].resolved_geometry().edge_margin, 0);
|
||||
assert_eq!(panels[0].geometry.side_margins, Some(18));
|
||||
@@ -506,15 +546,21 @@ animations { speed 0.8; }
|
||||
|
||||
#[test]
|
||||
fn morph_attachment_restores_floating_margin_and_preserves_clearance() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into())
|
||||
.unwrap();
|
||||
let before = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let mut snapshot = Snapshot::parse(
|
||||
"panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let before =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
apply(&mut snapshot, Action::SetMorphAttached(true));
|
||||
let attached = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let attached =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(attached[0].resolved_geometry().edge_margin, 0);
|
||||
assert_eq!(attached[0].resolved_geometry().window_clearance, 3);
|
||||
assert_eq!(attached[0].exclusive_zone(false), before[0].exclusive_zone(false));
|
||||
assert_eq!(
|
||||
attached[0].exclusive_zone(false),
|
||||
before[0].exclusive_zone(false)
|
||||
);
|
||||
assert!(attached[0].shape_parameters().attached);
|
||||
apply(&mut snapshot, Action::SetMorphAttached(false));
|
||||
assert_eq!(
|
||||
@@ -525,10 +571,14 @@ animations { speed 0.8; }
|
||||
|
||||
#[test]
|
||||
fn hover_preference_round_trips_without_changing_shape_or_items() {
|
||||
let mut snapshot = Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into()).unwrap();
|
||||
let before = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let mut snapshot =
|
||||
Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into())
|
||||
.unwrap();
|
||||
let before =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
apply(&mut snapshot, Action::SetPeekOnHover(false));
|
||||
let after = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let after =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert!(!after[0].morph.peek_on_hover);
|
||||
let mut expected = before.clone();
|
||||
expected[0].morph.peek_on_hover = false;
|
||||
@@ -569,7 +619,8 @@ animations { speed 0.8; }
|
||||
serde_json::from_value(value).unwrap()
|
||||
};
|
||||
apply(&mut snapshot, Action::SetBorder(false));
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert!(!panels[0].border);
|
||||
assert_eq!(panels[0].start, original[0].start);
|
||||
assert_eq!(panels[0].center, original[0].center);
|
||||
@@ -585,7 +636,8 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::SetCorner(0, 9.));
|
||||
apply(&mut snapshot, Action::SetCorner(3, 21.));
|
||||
let radii: ferese_panel::CornerRadii =
|
||||
serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
||||
serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone())
|
||||
.unwrap();
|
||||
assert_eq!(radii.0, [9., 8., 12., 21.]);
|
||||
let previous = snapshot.source.clone();
|
||||
for action in [
|
||||
@@ -600,15 +652,19 @@ animations { speed 0.8; }
|
||||
|
||||
#[test]
|
||||
fn panel_corner_override_is_independent_and_clearing_or_removing_restores_inheritance() {
|
||||
let mut snapshot = Snapshot::parse("theme { geometry { shell-radius 14; }; }".into()).unwrap();
|
||||
let mut snapshot =
|
||||
Snapshot::parse("theme { geometry { shell-radius 14; }; }".into()).unwrap();
|
||||
assert!(plan(&snapshot, Action::ClearRadius).unwrap().is_empty());
|
||||
apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into()));
|
||||
let resolve = |snapshot: &Snapshot, radius| {
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
panels[0].resolved_radius(radius).0
|
||||
};
|
||||
assert_eq!(resolve(&snapshot, 14.), [4., 8., 12., 16.]);
|
||||
snapshot.edit(&set("theme.geometry.shell_radius", 20)).unwrap();
|
||||
snapshot
|
||||
.edit(&set("theme.geometry.shell_radius", 20))
|
||||
.unwrap();
|
||||
assert_eq!(resolve(&snapshot, 20.), [4., 8., 12., 16.]);
|
||||
let original = snapshot.source.clone();
|
||||
assert!(plan(&snapshot, Action::SetRadius("-4px".into())).is_err());
|
||||
@@ -631,7 +687,8 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom));
|
||||
let before = snapshot.item("panels.0.start").unwrap().clone();
|
||||
assert_eq!(snapshot.string("panels.0.edge", ""), "bottom");
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let battery = panels[0].item(&ItemId("battery".into())).unwrap();
|
||||
assert_eq!(battery.kind, ItemKind::Battery { percentage: true });
|
||||
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top));
|
||||
@@ -661,7 +718,8 @@ animations { speed 0.8; }
|
||||
for edit in edits {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let battery = panels[0].item(&ItemId("battery".into())).unwrap();
|
||||
assert_eq!(battery.kind, ItemKind::Battery { percentage: true });
|
||||
assert!(!battery.visible);
|
||||
@@ -681,12 +739,18 @@ animations { speed 0.8; }
|
||||
&mut snapshot,
|
||||
Action::Move(ItemId("clock".into()), target("start", "navigation")),
|
||||
);
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups.0.items"), ["overview", "clock"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups.0.items"),
|
||||
["overview", "clock"]
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.start.groups.0.items.1.metadata.custom_note", ""),
|
||||
"keep"
|
||||
);
|
||||
assert_eq!(snapshot.string("panels.0.end.groups.0.items.0.overflow", ""), "always");
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.end.groups.0.items.0.overflow", ""),
|
||||
"always"
|
||||
);
|
||||
assert!(snapshot.source.contains("// first clock"));
|
||||
assert_eq!(snapshot.number("animations.speed", 1.0), 0.8);
|
||||
}
|
||||
@@ -694,7 +758,10 @@ animations { speed 0.8; }
|
||||
#[test]
|
||||
fn repeated_kind_additions_get_unique_ids_and_remove_only_the_addressed_instance() {
|
||||
let mut snapshot = snapshot();
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("end", "status")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("end", "status")),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock", "clock-2", "network", "clock-3"]
|
||||
@@ -704,7 +771,10 @@ animations { speed 0.8; }
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock", "network", "clock-3"]
|
||||
);
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("end", "status")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("end", "status")),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock", "network", "clock-3", "clock-2"]
|
||||
@@ -721,7 +791,10 @@ animations { speed 0.8; }
|
||||
);
|
||||
assert!(plan(&snapshot, pending).unwrap().is_empty());
|
||||
apply(&mut snapshot, Action::Earlier(ItemId("network".into())));
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups.0.items"), ["network", "overview"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups.0.items"),
|
||||
["network", "overview"]
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::Earlier(ItemId("network".into())))
|
||||
.unwrap()
|
||||
@@ -738,9 +811,18 @@ animations { speed 0.8; }
|
||||
Action::Move(ItemId("network".into()), target("start", "navigation")),
|
||||
);
|
||||
apply(&mut snapshot, pending);
|
||||
assert_eq!(snapshot.string("panels.0.start.groups.0.items.1.id", ""), "network");
|
||||
assert_eq!(snapshot.string("panels.0.start.groups.0.items.1.overflow", ""), "never");
|
||||
assert_eq!(snapshot.string("panels.0.end.groups.0.items.1.id", ""), "clock-2");
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.start.groups.0.items.1.id", ""),
|
||||
"network"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.start.groups.0.items.1.overflow", ""),
|
||||
"never"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.end.groups.0.items.1.id", ""),
|
||||
"clock-2"
|
||||
);
|
||||
assert!(
|
||||
plan(
|
||||
&snapshot,
|
||||
@@ -766,7 +848,10 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::Remove(ItemId("title".into())));
|
||||
assert_eq!(snapshot.records("panels.0.center.groups"), 1);
|
||||
assert_eq!(snapshot.records("panels.0.center.groups.0.items"), 0);
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("center", "title")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("center", "title")),
|
||||
);
|
||||
assert_eq!(snapshot.records("panels.0.center.groups.0.items"), 1);
|
||||
}
|
||||
|
||||
@@ -784,7 +869,10 @@ animations { speed 0.8; }
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
let complete = Snapshot::parse(snapshot.source).unwrap();
|
||||
assert_eq!(ids(&complete, "panels.0.center.groups.0.items"), ["title", "clock"]);
|
||||
assert_eq!(
|
||||
ids(&complete, "panels.0.center.groups.0.items"),
|
||||
["title", "clock"]
|
||||
);
|
||||
let restored = Snapshot::parse(original.source).unwrap();
|
||||
assert_eq!(restored.doc.value(), original.doc.value());
|
||||
}
|
||||
@@ -801,7 +889,10 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::SetOpacity(0.45));
|
||||
apply(&mut snapshot, Action::MoveGroup(id.clone(), Zone::Start));
|
||||
apply(&mut snapshot, Action::EarlierGroup(id.clone()));
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups"), ["status", "navigation"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups"),
|
||||
["status", "navigation"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups.0.items"),
|
||||
["clock", "clock-2", "network"]
|
||||
@@ -813,11 +904,26 @@ animations { speed 0.8; }
|
||||
assert_eq!(snapshot.number("panels.0.start.groups.0.spacing", 0.), 7.5);
|
||||
assert!((snapshot.number("panels.0.background_opacity", 1.) - 0.45).abs() < 0.0001);
|
||||
assert!(snapshot.source.contains("// first clock"));
|
||||
assert!(plan(&snapshot, Action::EarlierGroup(id.clone())).unwrap().is_empty());
|
||||
assert!(
|
||||
plan(&snapshot, Action::EarlierGroup(id.clone()))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
apply(&mut snapshot, Action::LaterGroup(id.clone()));
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups"), ["navigation", "status"]);
|
||||
assert!(plan(&snapshot, Action::LaterGroup(id.clone())).unwrap().is_empty());
|
||||
assert!(plan(&snapshot, Action::MoveGroup(id, Zone::Start)).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups"),
|
||||
["navigation", "status"]
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::LaterGroup(id.clone()))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::MoveGroup(id, Zone::Start))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
|
||||
}
|
||||
|
||||
@@ -860,10 +966,16 @@ animations { speed 0.8; }
|
||||
for _ in 0..3 {
|
||||
apply(&mut snapshot, Action::AddGroup(Zone::End));
|
||||
}
|
||||
assert_eq!(ids(&snapshot, "panels.0.end.groups"), ["end-1", "end-2", "end-3"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups"),
|
||||
["end-1", "end-2", "end-3"]
|
||||
);
|
||||
apply(&mut snapshot, Action::SetBorder(false));
|
||||
assert!(!snapshot.boolean("panels.0.border", true));
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("end", "end-2")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("end", "end-2")),
|
||||
);
|
||||
let original = snapshot.source.clone();
|
||||
assert!(plan(&snapshot, Action::RemoveGroup(GroupId("end-2".into()))).is_err());
|
||||
assert_eq!(snapshot.source, original);
|
||||
@@ -896,7 +1008,13 @@ animations { speed 0.8; }
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(plan(&snapshot, Action::MoveGroup(GroupId("missing".into()), Zone::Start)).is_err());
|
||||
assert!(
|
||||
plan(
|
||||
&snapshot,
|
||||
Action::MoveGroup(GroupId("missing".into()), Zone::Start)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(snapshot.source, original);
|
||||
}
|
||||
|
||||
@@ -904,7 +1022,10 @@ animations { speed 0.8; }
|
||||
fn drop_reorders_exact_instances_and_preserves_authored_fields() {
|
||||
let mut snapshot = snapshot();
|
||||
let id = ItemId("clock".into());
|
||||
apply(&mut snapshot, Action::Place(id.clone(), target("end", "status"), None));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Place(id.clone(), target("end", "status"), None),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock-2", "network", "clock"]
|
||||
@@ -914,9 +1035,12 @@ animations { speed 0.8; }
|
||||
"keep"
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::Place(id.clone(), target("end", "status"), None))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
plan(
|
||||
&snapshot,
|
||||
Action::Place(id.clone(), target("end", "status"), None)
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
plan(
|
||||
@@ -928,7 +1052,11 @@ animations { speed 0.8; }
|
||||
);
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Place(id.clone(), target("end", "status"), Some(ItemId("clock-2".into()))),
|
||||
Action::Place(
|
||||
id.clone(),
|
||||
target("end", "status"),
|
||||
Some(ItemId("clock-2".into())),
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
@@ -938,8 +1066,14 @@ animations { speed 0.8; }
|
||||
&mut snapshot,
|
||||
Action::Place(id, target("center", "title"), Some(ItemId("title".into()))),
|
||||
);
|
||||
assert_eq!(ids(&snapshot, "panels.0.center.groups.0.items"), ["clock", "title"]);
|
||||
assert_eq!(ids(&snapshot, "panels.0.end.groups.0.items"), ["clock-2", "network"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.center.groups.0.items"),
|
||||
["clock", "title"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock-2", "network"]
|
||||
);
|
||||
assert!(snapshot.source.contains("// first clock"));
|
||||
}
|
||||
|
||||
@@ -961,7 +1095,11 @@ animations { speed 0.8; }
|
||||
assert_eq!(snapshot.source, original);
|
||||
apply(&mut snapshot, Action::Remove(ItemId("title".into())));
|
||||
apply(&mut snapshot, Action::RemoveGroup(GroupId("title".into())));
|
||||
let edits = plan(&snapshot, Action::PlaceInZone(ItemId("clock".into()), Zone::Center)).unwrap();
|
||||
let edits = plan(
|
||||
&snapshot,
|
||||
Action::PlaceInZone(ItemId("clock".into()), Zone::Center),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edits.len(), 3, "create and move through one save");
|
||||
for edit in edits {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
@@ -984,9 +1122,14 @@ animations { speed 0.8; }
|
||||
for style in ferese_panel::WorkspaceStyle::ALL {
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Set(ItemId("workspaces".into()), "style".into(), style.key().into()),
|
||||
Action::Set(
|
||||
ItemId("workspaces".into()),
|
||||
"style".into(),
|
||||
style.key().into(),
|
||||
),
|
||||
);
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panel = &panels[0];
|
||||
assert_eq!(
|
||||
panel.item(&ItemId("workspaces".into())).unwrap().kind,
|
||||
@@ -1003,7 +1146,8 @@ animations { speed 0.8; }
|
||||
&mut snapshot,
|
||||
Action::Move(ItemId("workspaces".into()), target("end", "status")),
|
||||
);
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panel = &panels[0];
|
||||
assert_eq!(
|
||||
panel.item(&ItemId("workspaces".into())).unwrap().kind,
|
||||
@@ -1025,7 +1169,8 @@ animations { speed 0.8; }
|
||||
Action::Set(ItemId("title".into()), "visible".into(), true.into()),
|
||||
);
|
||||
assert!(snapshot.source.contains("// keep") && snapshot.source.contains("custom-note"));
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert!(panels[0].item(&ItemId("title".into())).unwrap().visible);
|
||||
}
|
||||
#[test]
|
||||
@@ -1037,7 +1182,10 @@ animations { speed 0.8; }
|
||||
Action::ClearGeometry("panels.0.geometry.side_margins".into()),
|
||||
);
|
||||
assert_eq!(snapshot.item("panels.0.geometry.side_margins"), None);
|
||||
assert_eq!(snapshot.item("panels.0.geometry.height"), Some(&serde_json::json!(32)));
|
||||
assert_eq!(
|
||||
snapshot.item("panels.0.geometry.height"),
|
||||
Some(&serde_json::json!(32))
|
||||
);
|
||||
assert!(snapshot.item("panels.0.corner_radius").is_some());
|
||||
apply(&mut snapshot, Action::ResetShape);
|
||||
assert_eq!(snapshot.item("panels.0.geometry"), None);
|
||||
@@ -1050,13 +1198,20 @@ animations { speed 0.8; }
|
||||
fn panel_presets_preserve_group_layout_items_and_comments() {
|
||||
let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; center { group title { padding 2 7; item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap();
|
||||
let groups = snapshot.item("panels.0.center.groups").unwrap().clone();
|
||||
apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Flat));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::ApplyPreset(ferese_panel::PanelStyle::Flat),
|
||||
);
|
||||
assert_eq!(snapshot.string("panels.0.style", ""), "flat");
|
||||
assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups));
|
||||
assert!(snapshot.source.contains("// keep"));
|
||||
apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Floating));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::ApplyPreset(ferese_panel::PanelStyle::Floating),
|
||||
);
|
||||
assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups));
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Floating));
|
||||
for surface in ["none", "inset", "island"] {
|
||||
assert!(
|
||||
|
||||
@@ -18,7 +18,10 @@ impl App {
|
||||
.push(
|
||||
widget::row([])
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.panel_heading(title.to_owned(), 16.).width(Length::Fill))
|
||||
.push(
|
||||
self.panel_heading(title.to_owned(), 16.)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(self.settings_icon_button(
|
||||
"Deselect",
|
||||
"M6 6l12 12 M6 18L18 6",
|
||||
@@ -33,7 +36,10 @@ impl App {
|
||||
pub(super) fn panel_divider(&self) -> Element<'static, Message> {
|
||||
let p = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
widget::container(widget::Space::new().height(1).width(Length::Fill))
|
||||
.class(crate::visuals::surface(ferese_theme::mix(p.card, p.text, 0.09), 0.))
|
||||
.class(crate::visuals::surface(
|
||||
ferese_theme::mix(p.card, p.text, 0.09),
|
||||
0.,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -57,7 +63,9 @@ impl App {
|
||||
cosmic::iced::widget::pick_list(
|
||||
crate::panel_edit::destinations(panel),
|
||||
Some(destination.clone()),
|
||||
move |destination| Message::PanelEdit(Action::Move(item_id.clone(), destination)),
|
||||
move |destination| {
|
||||
Message::PanelEdit(Action::Move(item_id.clone(), destination))
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(13)
|
||||
@@ -68,7 +76,12 @@ impl App {
|
||||
);
|
||||
rows = rows.push(self.panel_field(
|
||||
item,
|
||||
Field::new(format!("{path}.visible"), "Show item", "", Kind::Toggle(true)),
|
||||
Field::new(
|
||||
format!("{path}.visible"),
|
||||
"Show item",
|
||||
"",
|
||||
Kind::Toggle(true),
|
||||
),
|
||||
));
|
||||
rows = rows.push(self.panel_divider());
|
||||
if matches!(item.kind, ItemKind::Workspaces { .. }) {
|
||||
@@ -83,14 +96,20 @@ impl App {
|
||||
"Choose when to use the overflow menu.",
|
||||
Kind::Choice {
|
||||
default: "auto",
|
||||
choices: &[("never", "Keep visible"), ("auto", "When needed"), ("always", "Always")],
|
||||
choices: &[
|
||||
("never", "Keep visible"),
|
||||
("auto", "When needed"),
|
||||
("always", "Always"),
|
||||
],
|
||||
},
|
||||
),
|
||||
));
|
||||
let representations = item.kind.representations();
|
||||
if representations.len() > 1 {
|
||||
let choices: &'static [(&'static str, &'static str)] = match representations {
|
||||
[Representation::Wide, Representation::Icon] => &[("wide", "Full"), ("icon", "Icon")],
|
||||
[Representation::Wide, Representation::Icon] => {
|
||||
&[("wide", "Full"), ("icon", "Icon")]
|
||||
}
|
||||
_ => &[("wide", "Full"), ("compact", "Compact"), ("icon", "Icon")],
|
||||
};
|
||||
rows = rows.push(self.panel_field(
|
||||
@@ -128,12 +147,15 @@ impl App {
|
||||
.position(|candidate| candidate.id == item.id)
|
||||
.unwrap();
|
||||
rows.push(self.panel_divider())
|
||||
.push(self.inspector_actions(
|
||||
(index > 0).then(|| Message::PanelEdit(Action::Earlier(item.id.clone()))),
|
||||
(index + 1 < group.items.len()).then(|| Message::PanelEdit(Action::Later(item.id.clone()))),
|
||||
"Remove item",
|
||||
Some(Message::PanelEdit(Action::Remove(item.id.clone()))),
|
||||
))
|
||||
.push(
|
||||
self.inspector_actions(
|
||||
(index > 0).then(|| Message::PanelEdit(Action::Earlier(item.id.clone()))),
|
||||
(index + 1 < group.items.len())
|
||||
.then(|| Message::PanelEdit(Action::Later(item.id.clone()))),
|
||||
"Remove item",
|
||||
Some(Message::PanelEdit(Action::Remove(item.id.clone()))),
|
||||
),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -159,16 +181,24 @@ impl App {
|
||||
.padding([8, 10])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, style == selected))
|
||||
.on_press_maybe(
|
||||
(style != selected)
|
||||
.then(|| Message::PanelEdit(Action::Set(item.id.clone(), "style".into(), style.key().into()))),
|
||||
),
|
||||
.on_press_maybe((style != selected).then(|| {
|
||||
Message::PanelEdit(Action::Set(
|
||||
item.id.clone(),
|
||||
"style".into(),
|
||||
style.key().into(),
|
||||
))
|
||||
})),
|
||||
);
|
||||
}
|
||||
choices.into()
|
||||
}
|
||||
|
||||
pub(super) fn group_inspector(&self, panel: &Panel, group: &Group, placement: Zone) -> Element<'static, Message> {
|
||||
pub(super) fn group_inspector(
|
||||
&self,
|
||||
panel: &Panel,
|
||||
group: &Group,
|
||||
placement: Zone,
|
||||
) -> Element<'static, Message> {
|
||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let id = group.id.clone();
|
||||
let mut rows = widget::column([])
|
||||
@@ -182,9 +212,11 @@ impl App {
|
||||
.spacing(8)
|
||||
.push(self.label("Placement", 13.))
|
||||
.push(crate::visuals::panel_select(
|
||||
cosmic::iced::widget::pick_list(Zone::ALL.to_vec(), Some(placement), move |zone| {
|
||||
Message::PanelEdit(Action::MoveGroup(id.clone(), zone))
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
Zone::ALL.to_vec(),
|
||||
Some(placement),
|
||||
move |zone| Message::PanelEdit(Action::MoveGroup(id.clone(), zone)),
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(13)
|
||||
.padding([8, 10])
|
||||
@@ -196,16 +228,28 @@ impl App {
|
||||
let mut spacing = widget::column([]).spacing(12);
|
||||
for (name, label, value, maximum) in [
|
||||
("spacing", "Item spacing", group.spacing, 64.),
|
||||
("padding_vertical", "Vertical padding", f32::from(group.padding[0]), 32.),
|
||||
(
|
||||
"padding_vertical",
|
||||
"Vertical padding",
|
||||
f32::from(group.padding[0]),
|
||||
32.,
|
||||
),
|
||||
(
|
||||
"padding_horizontal",
|
||||
"Horizontal padding",
|
||||
f32::from(group.padding[1]),
|
||||
32.,
|
||||
),
|
||||
("island_padding", "Island padding", group.island_padding, 32.),
|
||||
(
|
||||
"island_padding",
|
||||
"Island padding",
|
||||
group.island_padding,
|
||||
32.,
|
||||
),
|
||||
] {
|
||||
if panel.group_surface() != ferese_panel::GroupSurface::Island && name == "island_padding" {
|
||||
if panel.group_surface() != ferese_panel::GroupSurface::Island
|
||||
&& name == "island_padding"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let change = |value: f32| {
|
||||
@@ -263,7 +307,8 @@ impl App {
|
||||
),
|
||||
);
|
||||
if !group.items.is_empty() {
|
||||
rows = rows.push(self.note("Move or remove this group’s items before removing the group."));
|
||||
rows = rows
|
||||
.push(self.note("Move or remove this group’s items before removing the group."));
|
||||
}
|
||||
rows.into()
|
||||
}
|
||||
@@ -294,7 +339,10 @@ impl App {
|
||||
let values = serde_json::to_value(item).expect("serializable panel item");
|
||||
match field.kind {
|
||||
Kind::Choice { default, choices } => {
|
||||
let value = values.get(&name).and_then(serde_json::Value::as_str).unwrap_or(default);
|
||||
let value = values
|
||||
.get(&name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(default);
|
||||
if name == "overflow" {
|
||||
let selected = choices
|
||||
.iter()
|
||||
@@ -311,7 +359,11 @@ impl App {
|
||||
.collect::<Vec<_>>(),
|
||||
selected,
|
||||
move |choice| {
|
||||
Message::PanelEdit(Action::Set(id.clone(), name.clone(), choice.0.into()))
|
||||
Message::PanelEdit(Action::Set(
|
||||
id.clone(),
|
||||
name.clone(),
|
||||
choice.0.into(),
|
||||
))
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
@@ -329,16 +381,23 @@ impl App {
|
||||
.padding([8, 4])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(p, value == key))
|
||||
.on_press(Message::PanelEdit(Action::Set(id.clone(), name.clone(), key.into())))
|
||||
.on_press(Message::PanelEdit(Action::Set(
|
||||
id.clone(),
|
||||
name.clone(),
|
||||
key.into(),
|
||||
)))
|
||||
.into()
|
||||
}))
|
||||
.spacing(2)
|
||||
.width(Length::Fill);
|
||||
let mut rows = widget::column([]).spacing(8).push(self.label(field.label, 13.)).push(
|
||||
widget::container(options)
|
||||
.padding(3)
|
||||
.class(crate::visuals::surface(p.sidebar, 9.)),
|
||||
);
|
||||
let mut rows = widget::column([])
|
||||
.spacing(8)
|
||||
.push(self.label(field.label, 13.))
|
||||
.push(
|
||||
widget::container(options)
|
||||
.padding(3)
|
||||
.class(crate::visuals::surface(p.sidebar, 9.)),
|
||||
);
|
||||
if !field.description.is_empty() {
|
||||
rows = rows.push(self.note(&field.description));
|
||||
}
|
||||
@@ -355,7 +414,11 @@ impl App {
|
||||
.push(self.label(field.label.clone(), 13.).width(Length::Fill))
|
||||
.push(
|
||||
ferese_theme::controls::switch(enabled, p)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.name(format!(
|
||||
"{}: {}",
|
||||
field.label,
|
||||
if enabled { "on" } else { "off" }
|
||||
))
|
||||
.on_press(Message::PanelEdit(Action::Set(id, name, (!enabled).into()))),
|
||||
)
|
||||
.into()
|
||||
|
||||
@@ -20,7 +20,10 @@ fn available(item: &Item) -> bool {
|
||||
item.available(SAMPLE_AVAILABILITY)
|
||||
}
|
||||
|
||||
fn panel_background(theme: &ferese_config::theme::ResolvedTheme, panel: &Panel) -> cosmic::iced::Color {
|
||||
fn panel_background(
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
panel: &Panel,
|
||||
) -> cosmic::iced::Color {
|
||||
let fill = if panel.style == ferese_panel::PanelStyle::Morph {
|
||||
&theme.tokens.colors.surface_raised
|
||||
} else {
|
||||
@@ -81,7 +84,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
if !self.panel_preview_resolution.overflow.is_empty() {
|
||||
rows = rows.push(container(crate::scrollable(items).height(Length::Shrink)).max_height(160.));
|
||||
rows = rows.push(
|
||||
container(crate::scrollable(items).height(Length::Shrink)).max_height(160.),
|
||||
);
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
@@ -97,7 +102,8 @@ impl App {
|
||||
let panels = match self.draft.item("panels") {
|
||||
Some(panels) => panels.clone(),
|
||||
None => {
|
||||
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)? else {
|
||||
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
panels
|
||||
@@ -124,7 +130,9 @@ impl App {
|
||||
.into(),
|
||||
ferese_theme::panel_surface::Options {
|
||||
parameters: panel.shape_parameters(),
|
||||
bounds: Some(cosmic::iced::advanced::widget::Id::new("morph-preview-body")),
|
||||
bounds: Some(cosmic::iced::advanced::widget::Id::new(
|
||||
"morph-preview-body",
|
||||
)),
|
||||
groups: Vec::new(),
|
||||
scale: self.render_scale,
|
||||
high_contrast: palette.high_contrast,
|
||||
@@ -186,7 +194,10 @@ impl App {
|
||||
.flat_map(|(position, zone)| {
|
||||
zone.groups.iter().map(move |group| {
|
||||
(
|
||||
cosmic::iced::advanced::widget::Id::new(format!("preview-group:{}", group.id.0)),
|
||||
cosmic::iced::advanced::widget::Id::new(format!(
|
||||
"preview-group:{}",
|
||||
group.id.0
|
||||
)),
|
||||
position,
|
||||
)
|
||||
})
|
||||
@@ -233,7 +244,11 @@ impl App {
|
||||
let has_end = panel.end.groups.iter().flat_map(|group| &group.items)
|
||||
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.));
|
||||
end = row([])
|
||||
.spacing(if has_end { panel.zone_spacing(&panel.end) } else { 0. })
|
||||
.spacing(if has_end {
|
||||
panel.zone_spacing(&panel.end)
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.align_y(Alignment::Center)
|
||||
.push(trigger)
|
||||
.push(end)
|
||||
@@ -247,7 +262,9 @@ impl App {
|
||||
.push(start)
|
||||
.push(cosmic::widget::Space::new().width(Length::Fill))
|
||||
.push(end),
|
||||
container(center).center_x(Length::Fill).center_y(Length::Fill),
|
||||
container(center)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
]
|
||||
.into()
|
||||
},
|
||||
@@ -287,21 +304,34 @@ impl App {
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> {
|
||||
let mut groups = row([]).spacing(panel.zone_spacing(zone)).align_y(Alignment::Center);
|
||||
fn preview_zone(
|
||||
&self,
|
||||
panel: &Panel,
|
||||
zone: &Zone,
|
||||
resolution: &Resolution,
|
||||
) -> Element<'static, Message> {
|
||||
let mut groups = row([])
|
||||
.spacing(panel.zone_spacing(zone))
|
||||
.align_y(Alignment::Center);
|
||||
for group in &zone.groups {
|
||||
let mut controls = row([]).align_y(Alignment::Center);
|
||||
let mut count = 0;
|
||||
for item in &group.items {
|
||||
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id) else {
|
||||
let Some(Placement::Visible {
|
||||
representation,
|
||||
width,
|
||||
}) = resolution.items.get(&item.id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if *width <= 0. {
|
||||
continue;
|
||||
}
|
||||
if count > 0 {
|
||||
controls =
|
||||
controls.push(cosmic::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
|
||||
controls = controls.push(
|
||||
cosmic::widget::Space::new()
|
||||
.width(item.gap_before.unwrap_or(group.spacing)),
|
||||
);
|
||||
}
|
||||
controls = controls.push(self.preview_control(item, *representation, Some(*width)));
|
||||
count += 1;
|
||||
@@ -345,16 +375,21 @@ impl App {
|
||||
width: Option<f32>,
|
||||
) -> Element<'static, Message> {
|
||||
use ferese_theme::icons;
|
||||
let control_height = (self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.);
|
||||
let control_height =
|
||||
(self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.);
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
if let ItemKind::Workspaces { style } = item.kind {
|
||||
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
|
||||
let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
|
||||
.name("Edit Workspaces")
|
||||
.padding([0, 2])
|
||||
.height(control_height)
|
||||
.on_press(Message::PanelSelect(item.id.clone()))
|
||||
.class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id)));
|
||||
let control =
|
||||
button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
|
||||
.name("Edit Workspaces")
|
||||
.padding([0, 2])
|
||||
.height(control_height)
|
||||
.on_press(Message::PanelSelect(item.id.clone()))
|
||||
.class(preview_button(
|
||||
palette,
|
||||
self.panel_selection.as_ref() == Some(&item.id),
|
||||
));
|
||||
return container(control)
|
||||
.id(format!("preview-item:{}", item.id.0))
|
||||
.width(width.map_or(Length::Shrink, Length::Fixed))
|
||||
@@ -400,7 +435,10 @@ impl App {
|
||||
.height(Length::Fill)
|
||||
.spacing(5)
|
||||
.align_y(Alignment::Center)
|
||||
.push(container(icons::tinted(source, 16, palette.text)).id(format!("preview-icon:{}", item.id.0)));
|
||||
.push(
|
||||
container(icons::tinted(source, 16, palette.text))
|
||||
.id(format!("preview-icon:{}", item.id.0)),
|
||||
);
|
||||
|
||||
if !label.is_empty() {
|
||||
content = content.push(
|
||||
@@ -484,7 +522,9 @@ fn preview_button(palette: visuals::Palette, selected: bool) -> cosmic::theme::B
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::Application;
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget,
|
||||
};
|
||||
use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size};
|
||||
|
||||
#[test]
|
||||
@@ -564,14 +604,18 @@ mod tests {
|
||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||
tree.diff(view.as_widget_mut());
|
||||
let viewport = Rectangle::with_size(Size::new(width, 80.));
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
for _ in 0..3 {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
@@ -629,7 +673,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
if width == 2400. {
|
||||
let target = bounds.items.iter().find(|(id, _)| id == &clock).unwrap().1.center();
|
||||
let target = bounds
|
||||
.items
|
||||
.iter()
|
||||
.find(|(id, _)| id == &clock)
|
||||
.unwrap()
|
||||
.1
|
||||
.center();
|
||||
for event in [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
@@ -648,7 +698,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(message, Message::PanelSelect(id) if id == &clock))
|
||||
.filter(
|
||||
|message| matches!(message, Message::PanelSelect(id) if id == &clock)
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
@@ -680,7 +732,10 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.0;
|
||||
for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] {
|
||||
for background in [
|
||||
ferese_panel::PanelStyle::Flat,
|
||||
ferese_panel::PanelStyle::Floating,
|
||||
] {
|
||||
let mut panel = Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||
panel.apply_preset(background);
|
||||
// Match a three-group layout with media in navigation and a larger status group.
|
||||
@@ -711,7 +766,12 @@ mod tests {
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.map(|item| (widget::Id::new(format!("preview-item:{}", item.id.0)), item.id.clone()))
|
||||
.map(|item| {
|
||||
(
|
||||
widget::Id::new(format!("preview-item:{}", item.id.0)),
|
||||
item.id.clone(),
|
||||
)
|
||||
})
|
||||
.chain(std::iter::once((
|
||||
widget::Id::new("preview-item:_overflow"),
|
||||
ItemId("_overflow".into()),
|
||||
@@ -727,7 +787,10 @@ mod tests {
|
||||
.find(|(id, _)| id == &title.id)
|
||||
.expect("sample title remains visible")
|
||||
.1;
|
||||
assert!((center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}");
|
||||
assert!(
|
||||
(center.center_x() - width / 2.).abs() < 0.1,
|
||||
"{center:?} at {width}"
|
||||
);
|
||||
// Match the shell: titles yield space down to their shared minimum.
|
||||
assert!(center.width >= title.kind.minimum_width().unwrap().min(natural) - 0.1);
|
||||
assert!(center.width <= natural + 0.1);
|
||||
@@ -746,7 +809,11 @@ mod tests {
|
||||
} else {
|
||||
side.x - center.x - center.width
|
||||
};
|
||||
assert!(gap >= 7.9, "{background:?} at {width}: {} crowds title ({gap}px)", id.0);
|
||||
assert!(
|
||||
gap >= 7.9,
|
||||
"{background:?} at {width}: {} crowds title ({gap}px)",
|
||||
id.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -774,7 +841,10 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.0;
|
||||
for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] {
|
||||
for background in [
|
||||
ferese_panel::PanelStyle::Flat,
|
||||
ferese_panel::PanelStyle::Floating,
|
||||
] {
|
||||
let panel = Panel::from_preset(background);
|
||||
let ids: Vec<_> = [&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
@@ -796,25 +866,48 @@ mod tests {
|
||||
let mut controls = Bounds {
|
||||
targets: ids
|
||||
.iter()
|
||||
.map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id.clone()))
|
||||
.map(|id| {
|
||||
(
|
||||
widget::Id::new(format!("preview-item:{}", id.0)),
|
||||
id.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut icons = Bounds {
|
||||
targets: ids
|
||||
.iter()
|
||||
.map(|id| (widget::Id::new(format!("preview-icon:{}", id.0)), id.clone()))
|
||||
.map(|id| {
|
||||
(
|
||||
widget::Id::new(format!("preview-icon:{}", id.0)),
|
||||
id.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut controls);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut icons);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut controls,
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut icons,
|
||||
);
|
||||
assert!(!controls.items.is_empty());
|
||||
assert_eq!(controls.items.len(), icons.items.len());
|
||||
for (id, icon) in &icons.items {
|
||||
let control = controls.items.iter().find(|(item, _)| item == id).unwrap().1;
|
||||
let control = controls
|
||||
.items
|
||||
.iter()
|
||||
.find(|(item, _)| item == id)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert!(
|
||||
(icon.center_y() - control.center_y()).abs() < 0.01,
|
||||
"{background:?} at {width}: {} icon {icon:?} is off-center in {control:?}",
|
||||
@@ -863,14 +956,17 @@ mod tests {
|
||||
let mut baseline_bounds = None;
|
||||
for state in ["idle", "hovered", "pressed", "selected"] {
|
||||
app.panel_selection = (state == "selected").then(|| item.id.clone());
|
||||
let mut view: Element<'_, Message> = container(app.preview_control(&item, Representation::Icon, None))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
let mut view: Element<'_, Message> =
|
||||
container(app.preview_control(&item, Representation::Icon, None))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut bounds = Bounds {
|
||||
targets: vec![(widget::Id::new("preview-item:battery"), item.id.clone())],
|
||||
..Default::default()
|
||||
@@ -917,11 +1013,18 @@ mod tests {
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(96, 48), 1., backdrop);
|
||||
if let Some(baseline) = &baseline {
|
||||
let mut changed = 0;
|
||||
for (index, (before, after)) in baseline.chunks_exact(4).zip(pixels.chunks_exact(4)).enumerate() {
|
||||
for (index, (before, after)) in baseline
|
||||
.chunks_exact(4)
|
||||
.zip(pixels.chunks_exact(4))
|
||||
.enumerate()
|
||||
{
|
||||
if before != after {
|
||||
changed += 1;
|
||||
assert!(
|
||||
target.contains(Point::new((index % 96) as f32 + 0.5, (index / 96) as f32 + 0.5)),
|
||||
target.contains(Point::new(
|
||||
(index % 96) as f32 + 0.5,
|
||||
(index / 96) as f32 + 0.5
|
||||
)),
|
||||
"{state} paints outside the hit area"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ pub enum Page {
|
||||
impl Page {
|
||||
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [
|
||||
("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]),
|
||||
("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]),
|
||||
(
|
||||
"Interaction",
|
||||
&[Self::Windows, Self::Keyboard, Self::Accessibility],
|
||||
),
|
||||
(
|
||||
"System",
|
||||
&[
|
||||
@@ -43,7 +46,10 @@ impl Page {
|
||||
pub fn subpages(self) -> &'static [(Self, &'static str)] {
|
||||
match self.navigation_page() {
|
||||
Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")],
|
||||
Self::Keyboard => &[(Self::Keyboard, "Keyboard & mouse"), (Self::Shortcuts, "Shortcuts")],
|
||||
Self::Keyboard => &[
|
||||
(Self::Keyboard, "Keyboard & mouse"),
|
||||
(Self::Shortcuts, "Shortcuts"),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -114,7 +120,9 @@ impl Page {
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accessibility => "M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6",
|
||||
Self::Accessibility => {
|
||||
"M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6"
|
||||
}
|
||||
Self::Appearance => {
|
||||
"M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
|
||||
}
|
||||
@@ -132,12 +140,18 @@ impl Page {
|
||||
}
|
||||
Self::Startup => "M12 3v9 M7 5a9 9 0 1 0 10 0",
|
||||
Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8",
|
||||
Self::Connections => "M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01",
|
||||
Self::Connections => {
|
||||
"M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(self, query: &str) -> bool {
|
||||
self.matches_own(query) || self.subpages().iter().any(|(page, _)| page.matches_own(query))
|
||||
self.matches_own(query)
|
||||
|| self
|
||||
.subpages()
|
||||
.iter()
|
||||
.any(|(page, _)| page.matches_own(query))
|
||||
}
|
||||
|
||||
fn matches_own(self, query: &str) -> bool {
|
||||
@@ -182,7 +196,12 @@ pub struct Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn new(path: impl Into<String>, label: impl Into<String>, description: impl Into<String>, kind: Kind) -> Self {
|
||||
pub fn new(
|
||||
path: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
kind: Kind,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
label: label.into(),
|
||||
@@ -241,7 +260,15 @@ pub fn text(
|
||||
description: impl Into<String>,
|
||||
default: &'static str,
|
||||
) -> Field {
|
||||
Field::new(path, label, description, Kind::Text { default, argv: false })
|
||||
Field::new(
|
||||
path,
|
||||
label,
|
||||
description,
|
||||
Kind::Text {
|
||||
default,
|
||||
argv: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choice(
|
||||
|
||||
@@ -75,7 +75,11 @@ fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
|
||||
.unwrap();
|
||||
let mut app = App::init(
|
||||
Core::default(),
|
||||
("/unused/editor.kdl".into(), Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/unused/editor.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
app.page = Page::Bar;
|
||||
@@ -95,7 +99,10 @@ fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut before);
|
||||
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(200.) },
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(200.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
|
||||
@@ -129,7 +136,11 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
||||
if matches!(
|
||||
text,
|
||||
"Shell corner radius" | "Shell opacity" | "Background blur" | "Interface font" | "Focus border"
|
||||
"Shell corner radius"
|
||||
| "Shell opacity"
|
||||
| "Background blur"
|
||||
| "Interface font"
|
||||
| "Focus border"
|
||||
) {
|
||||
self.labels.push(bounds);
|
||||
}
|
||||
@@ -169,7 +180,11 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
let source = format!("theme {{ split #{split}; family \"ferese-blue\"; }}");
|
||||
let mut app = App::init(
|
||||
Core::default(),
|
||||
(PathBuf::from("/unused/scroll-test.kdl"), Snapshot::parse(source), None),
|
||||
(
|
||||
PathBuf::from("/unused/scroll-test.kdl"),
|
||||
Snapshot::parse(source),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
app.page = Page::Appearance;
|
||||
@@ -188,9 +203,11 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
let mut view = app.page_view();
|
||||
let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
tree.diff(view.as_widget_mut());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
let event = if !(4..52).contains(&frame) {
|
||||
Event::Window(window::Event::RedrawRequested(std::time::Instant::now()))
|
||||
} else {
|
||||
@@ -233,7 +250,8 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
if (4..28).contains(&frame) {
|
||||
assert!(
|
||||
position.offset > previous
|
||||
|| position.offset >= position.content_height - position.viewport_height - 0.5,
|
||||
|| position.offset
|
||||
>= position.content_height - position.viewport_height - 0.5,
|
||||
"scroll stopped at {position:?}"
|
||||
);
|
||||
}
|
||||
@@ -264,23 +282,35 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &renderer, &mut labels);
|
||||
let started = std::time::Instant::now();
|
||||
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
physical,
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
if (4..52).contains(&frame) {
|
||||
render_times.push(started.elapsed().as_micros());
|
||||
}
|
||||
if let Some(viewport) = labels.viewport {
|
||||
for bounds in labels.labels {
|
||||
let bounds = bounds - labels.translation;
|
||||
if bounds.y < viewport.y + 2. || bounds.y + bounds.height > viewport.y + viewport.height - 2. {
|
||||
if bounds.y < viewport.y + 2.
|
||||
|| bounds.y + bounds.height > viewport.y + viewport.height - 2.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut ink = 0;
|
||||
for y in (bounds.y * scale).ceil() as u32..((bounds.y + bounds.height) * scale).floor() as u32 {
|
||||
for x in
|
||||
(bounds.x * scale).ceil() as u32..((bounds.x + bounds.width) * scale).floor() as u32
|
||||
for y in (bounds.y * scale).ceil() as u32
|
||||
..((bounds.y + bounds.height) * scale).floor() as u32
|
||||
{
|
||||
for x in (bounds.x * scale).ceil() as u32
|
||||
..((bounds.x + bounds.width) * scale).floor() as u32
|
||||
{
|
||||
let index = ((y * physical.width + x) * 4) as usize;
|
||||
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
|
||||
if pixels[index..index + 3]
|
||||
.iter()
|
||||
.any(|channel| *channel > 140)
|
||||
{
|
||||
ink += 1;
|
||||
}
|
||||
}
|
||||
@@ -297,7 +327,10 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
for message in messages {
|
||||
if matches!(message, Message::RowVisibility(..)) {
|
||||
visibility_messages += 1;
|
||||
assert!(frame < 56, "visibility kept changing after scrolling stopped");
|
||||
assert!(
|
||||
frame < 56,
|
||||
"visibility kept changing after scrolling stopped"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.update(message);
|
||||
@@ -305,7 +338,10 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
}
|
||||
|
||||
render_times.sort_unstable();
|
||||
assert!(checked_labels > 20, "scroll check must include visible field labels");
|
||||
assert!(
|
||||
checked_labels > 20,
|
||||
"scroll check must include visible field labels"
|
||||
);
|
||||
eprintln!(
|
||||
"backend={backend} split={split} render_median_us={} p95_us={} visibility_messages={visibility_messages}",
|
||||
render_times[render_times.len() / 2],
|
||||
@@ -323,7 +359,9 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
.expect("rendering tests require fontconfig");
|
||||
assert!(matched.status.success());
|
||||
let matched = String::from_utf8(matched.stdout).unwrap();
|
||||
let (family, path) = matched.split_once('\n').expect("rendering tests require a system font");
|
||||
let (family, path) = matched
|
||||
.split_once('\n')
|
||||
.expect("rendering tests require a system font");
|
||||
let font = ferese_theme::font(Some(family.split(',').next().unwrap()));
|
||||
cosmic::iced::advanced::graphics::text::font_system()
|
||||
.write()
|
||||
@@ -393,7 +431,9 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
for (_, bounds, clip) in &mut self.labels[self.traversal_start..] {
|
||||
*bounds = *bounds - translation;
|
||||
*clip = Some(clip.map_or(viewport, |clip| {
|
||||
(clip - translation).intersection(&viewport).unwrap_or_default()
|
||||
(clip - translation)
|
||||
.intersection(&viewport)
|
||||
.unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -438,28 +478,37 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
} else {
|
||||
panel_controls::PanelPage::Arrange
|
||||
};
|
||||
app.panel_selection = (selection == "battery").then(|| ferese_panel::ItemId("battery".into()));
|
||||
app.panel_group_selection = (selection == "status").then(|| ferese_panel::GroupId("status".into()));
|
||||
app.panel_selection =
|
||||
(selection == "battery").then(|| ferese_panel::ItemId("battery".into()));
|
||||
app.panel_group_selection =
|
||||
(selection == "status").then(|| ferese_panel::GroupId("status".into()));
|
||||
let bounds = Rectangle::with_size(Size::new(width, 650.));
|
||||
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
|
||||
let theme = app.native_palette.native_theme();
|
||||
let mut view = app.page_view();
|
||||
let mut tree = Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
let mut checked = 0;
|
||||
for offset in [0., 180., 400.] {
|
||||
for id in ["settings-content", "panel-inspector-scroll"] {
|
||||
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new(id),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
let mut scroll =
|
||||
cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new(id),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut scroll,
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
|
||||
}
|
||||
renderer.reset(bounds);
|
||||
view.as_widget().draw(
|
||||
@@ -475,10 +524,13 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
mouse::Cursor::Unavailable,
|
||||
&bounds,
|
||||
);
|
||||
if let Some(mut overlay) =
|
||||
view.as_widget_mut()
|
||||
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
|
||||
{
|
||||
if let Some(mut overlay) = view.as_widget_mut().overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&bounds,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let overlay = overlay.as_overlay_mut();
|
||||
let node = overlay.layout(&renderer, bounds.size());
|
||||
renderer.with_layer(bounds, |renderer| {
|
||||
@@ -496,9 +548,18 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
});
|
||||
}
|
||||
let mut labels = Labels::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
||||
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut labels,
|
||||
);
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
physical,
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
for (label, rect, clip) in labels.labels {
|
||||
let clip = clip.unwrap_or(bounds);
|
||||
if rect.width <= 0.
|
||||
@@ -511,10 +572,17 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
continue;
|
||||
}
|
||||
let mut ink = 0;
|
||||
for y in (rect.y * scale).ceil() as u32..((rect.y + rect.height) * scale).floor() as u32 {
|
||||
for x in (rect.x * scale).ceil() as u32..((rect.x + rect.width) * scale).floor() as u32 {
|
||||
for y in (rect.y * scale).ceil() as u32
|
||||
..((rect.y + rect.height) * scale).floor() as u32
|
||||
{
|
||||
for x in (rect.x * scale).ceil() as u32
|
||||
..((rect.x + rect.width) * scale).floor() as u32
|
||||
{
|
||||
let index = ((y * physical.width + x) * 4) as usize;
|
||||
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
|
||||
if pixels[index..index + 3]
|
||||
.iter()
|
||||
.any(|channel| *channel > 140)
|
||||
{
|
||||
ink += 1;
|
||||
}
|
||||
}
|
||||
@@ -526,7 +594,10 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 6, "state={selection} must exercise visible panel labels");
|
||||
assert!(
|
||||
checked >= 6,
|
||||
"state={selection} must exercise visible panel labels"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,7 +647,12 @@ fn render_panel_review_states() {
|
||||
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
|
||||
for (name, tab, item, group) in [
|
||||
("panels", panel_controls::PanelPage::Panels, None, None),
|
||||
("floating-groups", panel_controls::PanelPage::Panels, None, None),
|
||||
(
|
||||
"floating-groups",
|
||||
panel_controls::PanelPage::Panels,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"group-spacing-inspector",
|
||||
panel_controls::PanelPage::Arrange,
|
||||
@@ -585,7 +661,12 @@ fn render_panel_review_states() {
|
||||
),
|
||||
("corners", panel_controls::PanelPage::Panels, None, None),
|
||||
("morph", panel_controls::PanelPage::Panels, None, None),
|
||||
("preview-hover", panel_controls::PanelPage::Panels, None, None),
|
||||
(
|
||||
"preview-hover",
|
||||
panel_controls::PanelPage::Panels,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
("navigation", panel_controls::PanelPage::Arrange, None, None),
|
||||
("shortcuts", panel_controls::PanelPage::Panels, None, None),
|
||||
("motion", panel_controls::PanelPage::Panels, None, None),
|
||||
@@ -616,10 +697,14 @@ fn render_panel_review_states() {
|
||||
};
|
||||
app.draft = Snapshot::parse(String::new()).unwrap();
|
||||
if matches!(name, "floating-groups" | "group-spacing-inspector") {
|
||||
let mut panel = ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||
let mut panel =
|
||||
ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||
panel.geometry.edge_margin = Some(6);
|
||||
app.draft
|
||||
.edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap()))
|
||||
.edit(&crate::store::set(
|
||||
"panels",
|
||||
serde_json::to_value(vec![panel]).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
if name == "morph" {
|
||||
@@ -652,9 +737,11 @@ fn render_panel_review_states() {
|
||||
let theme = app.native_palette.native_theme();
|
||||
let mut view = app.page_view();
|
||||
let mut tree = Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
#[derive(Default)]
|
||||
struct HoverTarget(Option<Rectangle>);
|
||||
impl Operation for HoverTarget {
|
||||
@@ -669,9 +756,18 @@ fn render_panel_review_states() {
|
||||
}
|
||||
let cursor = if name == "preview-hover" {
|
||||
let mut target = HoverTarget::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut target);
|
||||
mouse::Cursor::Available(target.0.expect("battery is visible in the review preview").center())
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut target,
|
||||
);
|
||||
mouse::Cursor::Available(
|
||||
target
|
||||
.0
|
||||
.expect("battery is visible in the review preview")
|
||||
.center(),
|
||||
)
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
@@ -690,10 +786,13 @@ fn render_panel_review_states() {
|
||||
cursor,
|
||||
&bounds,
|
||||
);
|
||||
if let Some(mut overlay) =
|
||||
view.as_widget_mut()
|
||||
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
|
||||
{
|
||||
if let Some(mut overlay) = view.as_widget_mut().overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&bounds,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let overlay = overlay.as_overlay_mut();
|
||||
let layout = overlay.layout(&renderer, bounds.size());
|
||||
renderer.with_layer(bounds, |renderer| {
|
||||
@@ -784,7 +883,9 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
for selection in [
|
||||
None,
|
||||
Some(Message::PanelSelect(ferese_panel::ItemId("battery".into()))),
|
||||
Some(Message::PanelGroupSelect(ferese_panel::GroupId("status".into()))),
|
||||
Some(Message::PanelGroupSelect(ferese_panel::GroupId(
|
||||
"status".into(),
|
||||
))),
|
||||
Some(Message::PanelClearSelection),
|
||||
] {
|
||||
if let Some(selection) = selection {
|
||||
@@ -799,10 +900,14 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
|
||||
);
|
||||
if initial.is_none() {
|
||||
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(180.) },
|
||||
);
|
||||
let mut scroll =
|
||||
cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &renderer, &mut scroll);
|
||||
}
|
||||
@@ -816,7 +921,10 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
let editor = panes.editor.unwrap();
|
||||
let inspector = panes.inspector.unwrap();
|
||||
assert!(editor.x + editor.width < inspector.x, "panes overlap");
|
||||
assert!(inspector.x + inspector.width <= width, "inspector leaves window");
|
||||
assert!(
|
||||
inspector.x + inspector.width <= width,
|
||||
"inspector leaves window"
|
||||
);
|
||||
assert!(editor.height > 100. && inspector.height > 100.);
|
||||
if let Some(initial) = &initial {
|
||||
assert_eq!(&panes, initial);
|
||||
@@ -890,22 +998,31 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
|
||||
assert_eq!(bounds.sidebar.is_none(), compact);
|
||||
assert_eq!(bounds.toggle.is_some(), compact);
|
||||
assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 204. });
|
||||
assert_eq!(bounds.page.unwrap().width, if compact { width } else { width - 204. });
|
||||
assert_eq!(
|
||||
bounds.page.unwrap().width,
|
||||
if compact { width } else { width - 204. }
|
||||
);
|
||||
}
|
||||
let _ = app.update(Message::Sidebar(true));
|
||||
assert!(app.sidebar_open);
|
||||
let mut view = app.page_view();
|
||||
let mut tree = Tree::new(&view);
|
||||
let size = Size::new(740., 650.);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
|
||||
let mut messages = Vec::new();
|
||||
{
|
||||
let viewport = Rectangle::with_size(size);
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree, Layout::new(&node), &renderer, &viewport, Vector::ZERO)
|
||||
.overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&viewport,
|
||||
Vector::ZERO,
|
||||
)
|
||||
.expect("compact navigation overlay");
|
||||
let overlay = overlay.as_overlay_mut();
|
||||
let layout = overlay.layout(&renderer, size);
|
||||
@@ -937,6 +1054,9 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
|
||||
assert!(!app.sidebar_open);
|
||||
let _ = app.update(Message::Sidebar(true));
|
||||
let _ = app.update(Message::Page(Page::Bar));
|
||||
assert!(!app.sidebar_open, "reselecting the current page closes navigation too");
|
||||
assert!(
|
||||
!app.sidebar_open,
|
||||
"reselecting the current page closes navigation too"
|
||||
);
|
||||
assert!(app.pending.is_empty() && !app.saving);
|
||||
}
|
||||
@@ -49,7 +49,10 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn string(&self, path: &str, fallback: &str) -> String {
|
||||
self.item(path).and_then(Value::as_str).unwrap_or(fallback).to_owned()
|
||||
self.item(path)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub fn number(&self, path: &str, fallback: f64) -> f64 {
|
||||
@@ -61,7 +64,9 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn records(&self, path: &str) -> usize {
|
||||
self.item(path).and_then(Value::as_array).map_or(0, |a| a.len())
|
||||
self.item(path)
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, |a| a.len())
|
||||
}
|
||||
|
||||
pub fn argv(&self, path: &str) -> String {
|
||||
@@ -129,7 +134,9 @@ pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
.arg(format!("--file-filter={filter}"))
|
||||
.arg("--file-filter=All files | *")
|
||||
.output()
|
||||
.map_err(|e| format!("Could not open the file chooser: {e}. Install zenity to choose files."))?;
|
||||
.map_err(|e| {
|
||||
format!("Could not open the file chooser: {e}. Install zenity to choose files.")
|
||||
})?;
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -139,7 +146,8 @@ pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
}
|
||||
@@ -204,7 +212,11 @@ fn save_with(
|
||||
if file.source() != expected {
|
||||
return Err("Your config changed in another app. Reload settings before saving.".into());
|
||||
}
|
||||
file.replace(desired, Some(&path.with_extension("kdl.settings-backup")), validate)?;
|
||||
file.replace(
|
||||
desired,
|
||||
Some(&path.with_extension("kdl.settings-backup")),
|
||||
validate,
|
||||
)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -332,11 +344,17 @@ window-rule app-id="mine" floating=#true
|
||||
|
||||
#[test]
|
||||
fn nested_array_edits_and_login_items_roundtrip() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into())
|
||||
.unwrap();
|
||||
snapshot.edit(&set("output_profiles.0.outputs.0.scale", 1.75)).unwrap();
|
||||
assert_eq!(snapshot.number("output_profiles.0.outputs.0.scale", 1.), 1.75);
|
||||
let mut snapshot = Snapshot::parse(
|
||||
"output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot
|
||||
.edit(&set("output_profiles.0.outputs.0.scale", 1.75))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snapshot.number("output_profiles.0.outputs.0.scale", 1.),
|
||||
1.75
|
||||
);
|
||||
snapshot
|
||||
.edit(&Edit::Add(
|
||||
"autostart".into(),
|
||||
@@ -366,12 +384,17 @@ window-rule app-id="mine" floating=#true
|
||||
#[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"]
|
||||
fn all_gui_controls_and_presets_pass_compositor_validation() {
|
||||
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY");
|
||||
let initial = Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap();
|
||||
let initial =
|
||||
Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let validate = |snapshot: &Snapshot| {
|
||||
fs::write(&path, &snapshot.source).unwrap();
|
||||
let result = Command::new(&binary).arg("--check-config").arg(&path).output().unwrap();
|
||||
let result = Command::new(&binary)
|
||||
.arg("--check-config")
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
@@ -385,7 +408,9 @@ window-rule app-id="mine" floating=#true
|
||||
use crate::schema::Kind;
|
||||
let values: Vec<Value> = match field.kind {
|
||||
Kind::Toggle(_) => vec![true.into(), false.into()],
|
||||
Kind::Range { min, max, integer, .. } => {
|
||||
Kind::Range {
|
||||
min, max, integer, ..
|
||||
} => {
|
||||
if integer {
|
||||
vec![(min as i64).into(), (max as i64).into()]
|
||||
} else {
|
||||
@@ -393,7 +418,9 @@ window-rule app-id="mine" floating=#true
|
||||
}
|
||||
}
|
||||
Kind::Text { default, .. } => vec![default.into()],
|
||||
Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(),
|
||||
Kind::Choice { choices, .. } => {
|
||||
choices.iter().map(|(v, _)| (*v).into()).collect()
|
||||
}
|
||||
};
|
||||
for value in values {
|
||||
let mut snapshot = initial.clone();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container, gallery, row,
|
||||
schema, set, text_input, visuals, widget,
|
||||
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container,
|
||||
gallery, row, schema, set, text_input, visuals, widget,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -10,7 +10,10 @@ pub(super) struct FamilySections {
|
||||
}
|
||||
|
||||
impl FamilySections {
|
||||
pub(super) fn new(families: &[ferese_config::families::Family], snapshot: &crate::store::Snapshot) -> Self {
|
||||
pub(super) fn new(
|
||||
families: &[ferese_config::families::Family],
|
||||
snapshot: &crate::store::Snapshot,
|
||||
) -> Self {
|
||||
let (custom, built_in) = (0..families.len()).partition(|&index| {
|
||||
snapshot
|
||||
.item(&format!("theme.custom_themes.{}", families[index].id))
|
||||
@@ -73,7 +76,10 @@ impl App {
|
||||
button::custom(self.label(label, 12.))
|
||||
.name(format!("Theme file: {label}"))
|
||||
.padding([4, 8])
|
||||
.class(visuals::button_style(palette, index == self.theme_file_target))
|
||||
.class(visuals::button_style(
|
||||
palette,
|
||||
index == self.theme_file_target,
|
||||
))
|
||||
.on_press(Message::ThemeFileTarget(index)),
|
||||
);
|
||||
}
|
||||
@@ -86,7 +92,11 @@ impl App {
|
||||
.class(visuals::surface(palette.sidebar, 10.)),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
if file.is_empty() { "Choose…" } else { "Replace…" },
|
||||
if file.is_empty() {
|
||||
"Choose…"
|
||||
} else {
|
||||
"Replace…"
|
||||
},
|
||||
"M3 7V5h6l2 2h10v13H3z",
|
||||
Some(Message::PickThemeFile(path)),
|
||||
false,
|
||||
@@ -171,7 +181,11 @@ impl App {
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(submit.clone()))
|
||||
.on_unfocus(Message::Commit(field.clone()))
|
||||
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
|
||||
.width(if field.path.ends_with("timezone") {
|
||||
150
|
||||
} else {
|
||||
75
|
||||
});
|
||||
times = times.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
@@ -189,7 +203,10 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'_, Message> {
|
||||
pub(super) fn theme_gallery(
|
||||
&self,
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
) -> Element<'_, Message> {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
|
||||
self.label(
|
||||
@@ -254,7 +271,10 @@ impl App {
|
||||
let content: Element<'_, Message> = if self.visible_rows.contains(&(list, chunk)) {
|
||||
self.theme_gallery_row(chunk, appearance, custom)
|
||||
} else {
|
||||
widget::Space::new().height(height).width(Length::Fill).into()
|
||||
widget::Space::new()
|
||||
.height(height)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
cosmic::iced::widget::sensor(
|
||||
container(content)
|
||||
@@ -293,7 +313,10 @@ impl App {
|
||||
) -> Option<(&'static str, usize)> {
|
||||
let index = self.family_ids.iter().position(|family| family == id)?;
|
||||
let (custom, position) = self.family_sections.position(index)?;
|
||||
Some((crate::navigation::gallery_list(appearance, custom), position / 3))
|
||||
Some((
|
||||
crate::navigation::gallery_list(appearance, custom),
|
||||
position / 3,
|
||||
))
|
||||
}
|
||||
|
||||
fn theme_gallery_row(
|
||||
|
||||
@@ -2,7 +2,9 @@ use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::Palette;
|
||||
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
|
||||
pub use ferese_theme::controls::{
|
||||
button_style, navigation_style, settings_input as input_style, surface,
|
||||
};
|
||||
|
||||
use crate::Message;
|
||||
use crate::schema::Page;
|
||||
@@ -100,16 +102,24 @@ pub fn split(snapshot: &Snapshot) -> bool {
|
||||
.item("theme.split")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
ferese_config::families::family_id(&snapshot.string("theme.light.preset", "ferese-blue-light"))
|
||||
!= ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue"))
|
||||
ferese_config::families::family_id(
|
||||
&snapshot.string("theme.light.preset", "ferese-blue-light"),
|
||||
) != ferese_config::families::family_id(
|
||||
&snapshot.string("theme.dark.preset", "ferese-blue"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn family_selection(snapshot: &Snapshot, appearance: Option<ferese_config::theme::Appearance>) -> String {
|
||||
pub fn family_selection(
|
||||
snapshot: &Snapshot,
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
) -> String {
|
||||
let Some(appearance) = appearance else {
|
||||
return snapshot.string(
|
||||
"theme.family",
|
||||
ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue")),
|
||||
ferese_config::families::family_id(
|
||||
&snapshot.string("theme.dark.preset", "ferese-blue"),
|
||||
),
|
||||
);
|
||||
};
|
||||
let kind = if appearance == ferese_config::theme::Appearance::Light {
|
||||
@@ -161,7 +171,11 @@ pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearan
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::theme::Appearance) -> Vec<Edit> {
|
||||
pub fn toggle_split(
|
||||
snapshot: &Snapshot,
|
||||
enabled: bool,
|
||||
active: ferese_config::theme::Appearance,
|
||||
) -> Vec<Edit> {
|
||||
let mut edits = vec![];
|
||||
if enabled {
|
||||
let id = family_selection(snapshot, None);
|
||||
@@ -183,14 +197,19 @@ pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::t
|
||||
}
|
||||
}
|
||||
} else {
|
||||
edits.push(set("theme.family", family_selection(snapshot, Some(active))));
|
||||
edits.push(set(
|
||||
"theme.family",
|
||||
family_selection(snapshot, Some(active)),
|
||||
));
|
||||
}
|
||||
edits.push(set("theme.split", enabled));
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn paint_style_overrides(snapshot: &Snapshot, edit: &Edit) -> Vec<Edit> {
|
||||
let Edit::Set(path, _) = edit else { return vec![] };
|
||||
let Edit::Set(path, _) = edit else {
|
||||
return vec![];
|
||||
};
|
||||
let section = match path.as_str() {
|
||||
"theme.focus_ring.style" => "focus_ring",
|
||||
"theme.border.style" => "border",
|
||||
@@ -223,7 +242,9 @@ pub fn preset(index: usize) -> Vec<Edit> {
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
let Some(preset) = PRESETS.get(index) else { return false };
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return false;
|
||||
};
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
@@ -242,27 +263,38 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
.all(|token| snapshot.item(&format!("theme.{kind}.{token}")).is_none())
|
||||
}
|
||||
|
||||
pub fn preview_resolved<'a>(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'a, Message> {
|
||||
pub fn preview_resolved<'a>(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'a, Message> {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.doc = snapshot.doc.with_theme(theme);
|
||||
preview(&snapshot, theme)
|
||||
}
|
||||
|
||||
fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'static, Message> {
|
||||
fn preview(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'static, Message> {
|
||||
let p = Palette::from_resolved(theme);
|
||||
let base = hex(p.sidebar);
|
||||
let card = hex(p.card);
|
||||
let accent = hex(p.accent);
|
||||
let muted = hex(p.muted);
|
||||
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
|
||||
let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.);
|
||||
let radius = snapshot
|
||||
.number("theme.geometry.window_radius", 14.)
|
||||
.clamp(0., 28.);
|
||||
let bar_y = snapshot.number("panels.0.geometry.edge_margin", 0.) * 0.5 + 12.;
|
||||
let bar_margin = snapshot.number("panels.0.geometry.side_margins", 0.) * 0.5 + 14.;
|
||||
let bar_height = snapshot.number("panels.0.geometry.height", 28.) * 0.65;
|
||||
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
|
||||
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
|
||||
snapshot
|
||||
.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY)
|
||||
.number(
|
||||
"theme.material.opacity",
|
||||
ferese_config::DEFAULT_MATERIAL_OPACITY,
|
||||
)
|
||||
.clamp(0., 1.)
|
||||
} else {
|
||||
1.
|
||||
@@ -344,7 +376,11 @@ mod tests {
|
||||
assert_eq!(theme.tokens.focus_ring.gradient.unwrap().from, from);
|
||||
}
|
||||
|
||||
assert!(snapshot.item(&format!("theme.{mode}.focus_ring.gradient")).is_some());
|
||||
assert!(
|
||||
snapshot
|
||||
.item(&format!("theme.{mode}.focus_ring.gradient"))
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,7 +447,10 @@ mod tests {
|
||||
family_selection(&snapshot, Some(ferese_config::theme::Appearance::Dark)),
|
||||
"catppuccin"
|
||||
);
|
||||
assert_eq!(snapshot.string("theme.dark.background.path", ""), "/wallpaper.png");
|
||||
assert_eq!(
|
||||
snapshot.string("theme.dark.background.path", ""),
|
||||
"/wallpaper.png"
|
||||
);
|
||||
assert_eq!(snapshot.string("theme.border.style", ""), "solid");
|
||||
for mode in ["light", "dark"] {
|
||||
assert!(snapshot.item(&format!("theme.{mode}.focus_ring")).is_none());
|
||||
@@ -432,7 +471,10 @@ mod tests {
|
||||
};
|
||||
let actual = resolve(&snapshot);
|
||||
let expected = resolve(&reference);
|
||||
assert_eq!(actual.tokens.focus_ring, expected.tokens.focus_ring, "{id}/{mode}");
|
||||
assert_eq!(
|
||||
actual.tokens.focus_ring, expected.tokens.focus_ring,
|
||||
"{id}/{mode}"
|
||||
);
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&actual).accent_gradient,
|
||||
Palette::from_resolved(&expected).accent_gradient
|
||||
@@ -463,8 +505,16 @@ mod tests {
|
||||
};
|
||||
let palette = Palette::from_resolved(&theme);
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
|
||||
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
|
||||
assert!(
|
||||
contrast(palette.text, background) >= 4.5,
|
||||
"{} text",
|
||||
preset.name
|
||||
);
|
||||
assert!(
|
||||
contrast(palette.muted, background) >= 4.5,
|
||||
"{} muted text",
|
||||
preset.name
|
||||
);
|
||||
}
|
||||
for other in PRESETS.iter().skip(index + 1) {
|
||||
if preset.appearance == other.appearance {
|
||||
@@ -486,18 +536,27 @@ mod tests {
|
||||
"dark"
|
||||
};
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.surface.bar.text_primary"), "#123456"))
|
||||
.edit(&set(
|
||||
&format!("theme.{kind}.surface.bar.text_primary"),
|
||||
"#123456",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
let mut snapshot = configured_preset(index);
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.focus_ring.gradient.to"), "#123456"))
|
||||
.edit(&set(
|
||||
&format!("theme.{kind}.focus_ring.gradient.to"),
|
||||
"#123456",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
}
|
||||
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
|
||||
assert!(preset(usize::MAX).is_empty());
|
||||
assert!(!preset_selected(&Snapshot::parse(String::new()).unwrap(), usize::MAX));
|
||||
assert!(!preset_selected(
|
||||
&Snapshot::parse(String::new()).unwrap(),
|
||||
usize::MAX
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -510,7 +569,10 @@ mod tests {
|
||||
..ferese_config::theme::default_theme()
|
||||
};
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
|
||||
Palette::from_resolved(&resolved)
|
||||
.native_theme()
|
||||
.cosmic()
|
||||
.is_dark,
|
||||
item.appearance == ferese_config::theme::Appearance::Dark
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,12 @@ impl Preview {
|
||||
true
|
||||
}
|
||||
|
||||
fn finish(&mut self, path: String, desired: &str, result: Result<widget::image::Handle, String>) {
|
||||
fn finish(
|
||||
&mut self,
|
||||
path: String,
|
||||
desired: &str,
|
||||
result: Result<widget::image::Handle, String>,
|
||||
) {
|
||||
if self.requested.as_deref() != Some(&path) {
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +176,11 @@ fn thumbnail(path: String, builtin: Option<Appearance>) -> Task<Message> {
|
||||
limits.max_image_width = Some(16384);
|
||||
limits.max_image_height = Some(16384);
|
||||
reader.limits(limits);
|
||||
let (width, height) = if builtin.is_some() { (400, 225) } else { (1000, 500) };
|
||||
let (width, height) = if builtin.is_some() {
|
||||
(400, 225)
|
||||
} else {
|
||||
(1000, 500)
|
||||
};
|
||||
let pixels = reader
|
||||
.decode()
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -240,9 +249,12 @@ impl App {
|
||||
return path;
|
||||
}
|
||||
|
||||
ferese_config::theme::theme_path(self.path.parent().unwrap_or(std::path::Path::new(".")), path.as_ref())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
ferese_config::theme::theme_path(
|
||||
self.path.parent().unwrap_or(std::path::Path::new(".")),
|
||||
path.as_ref(),
|
||||
)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn load_thumbnail(&mut self) -> Task<Message> {
|
||||
@@ -266,7 +278,11 @@ impl App {
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
pub(super) fn wallpaper_thumbnail(&mut self, path: String, result: Result<widget::image::Handle, String>) {
|
||||
pub(super) fn wallpaper_thumbnail(
|
||||
&mut self,
|
||||
path: String,
|
||||
result: Result<widget::image::Handle, String>,
|
||||
) {
|
||||
let desired = self.active_wallpaper_path();
|
||||
self.wallpaper.active.finish(path, &desired, result);
|
||||
}
|
||||
@@ -293,9 +309,13 @@ impl App {
|
||||
schema::fields(Page::Wallpaper)
|
||||
.into_iter()
|
||||
.map(|mut field| {
|
||||
field.path = field.path.replace("theme.background", self.wallpaper.target.prefix());
|
||||
field.path = field
|
||||
.path
|
||||
.replace("theme.background", self.wallpaper.target.prefix());
|
||||
if let Kind::Text { default, .. } = &mut field.kind {
|
||||
*default = ferese_config::default_wallpaper_for(self.wallpaper.target.appearance(active));
|
||||
*default = ferese_config::default_wallpaper_for(
|
||||
self.wallpaper.target.appearance(active),
|
||||
);
|
||||
}
|
||||
|
||||
field
|
||||
@@ -319,7 +339,9 @@ impl App {
|
||||
pub(super) fn wallpaper_controls(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let preview = &self.wallpaper.active;
|
||||
let mut body = column([]).spacing(8).push(self.label("Current wallpaper", 13.));
|
||||
let mut body = column([])
|
||||
.spacing(8)
|
||||
.push(self.label("Current wallpaper", 13.));
|
||||
let current: Element<'_, Message> = if let Some(handle) = &preview.handle {
|
||||
widget::image(handle.clone())
|
||||
.border_radius(palette.radius.min(10.))
|
||||
@@ -333,13 +355,20 @@ impl App {
|
||||
if preview.requested.is_some() {
|
||||
"Loading preview…"
|
||||
} else {
|
||||
preview.error.as_deref().unwrap_or("No wallpaper image selected.")
|
||||
preview
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("No wallpaper image selected.")
|
||||
},
|
||||
palette,
|
||||
180.,
|
||||
)
|
||||
};
|
||||
body = body.push(widget::container(current).height(180).center_x(Length::Fill));
|
||||
body = body.push(
|
||||
widget::container(current)
|
||||
.height(180)
|
||||
.center_x(Length::Fill),
|
||||
);
|
||||
|
||||
body = body.push(
|
||||
row([])
|
||||
@@ -347,9 +376,13 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Apply to", 12.))
|
||||
.push(ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(Target::ALL, Some(self.wallpaper.target), Message::WallpaperTarget)
|
||||
.font(self.font)
|
||||
.text_size(12),
|
||||
cosmic::iced::widget::pick_list(
|
||||
Target::ALL,
|
||||
Some(self.wallpaper.target),
|
||||
Message::WallpaperTarget,
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(12),
|
||||
palette,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
@@ -378,11 +411,18 @@ impl App {
|
||||
.content_fit(cosmic::iced::ContentFit::Cover)
|
||||
.into()
|
||||
} else {
|
||||
self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.)
|
||||
self.wallpaper_placeholder(
|
||||
preview.error.as_deref().unwrap_or("Loading preview…"),
|
||||
palette,
|
||||
110.,
|
||||
)
|
||||
};
|
||||
let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(palette.text)))
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill);
|
||||
let label = widget::container(
|
||||
self.label(name, 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.text)),
|
||||
)
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill);
|
||||
let tile = column([]).push(image).push(label);
|
||||
|
||||
defaults = defaults.push(
|
||||
@@ -467,7 +507,11 @@ mod tests {
|
||||
for width in [420., 760.] {
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
("/preview/config.kdl".into(), Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/preview/config.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
let dimensions = |app: &App| {
|
||||
@@ -495,7 +539,11 @@ mod tests {
|
||||
.finish("/active.png".into(), "/active.png", Ok(handle.clone()));
|
||||
|
||||
for index in 0..app.wallpaper.defaults.len() {
|
||||
app.wallpaper.defaults[index].finish("/default.png".into(), "/default.png", Ok(handle.clone()));
|
||||
app.wallpaper.defaults[index].finish(
|
||||
"/default.png".into(),
|
||||
"/default.png",
|
||||
Ok(handle.clone()),
|
||||
);
|
||||
assert_eq!(dimensions(&app), initial);
|
||||
}
|
||||
|
||||
@@ -526,13 +574,21 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
("/preview/config.kdl".into(), Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/preview/config.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
for appearance in [Appearance::Light, Appearance::Dark] {
|
||||
let path = ferese_config::default_wallpaper_for(appearance);
|
||||
let pixels = image::open(path).unwrap().thumbnail(400, 225).into_rgba8();
|
||||
let handle = widget::image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw());
|
||||
let handle = widget::image::Handle::from_rgba(
|
||||
pixels.width(),
|
||||
pixels.height(),
|
||||
pixels.into_raw(),
|
||||
);
|
||||
assert!(app.wallpaper.defaults[index(appearance)].request(path));
|
||||
app.default_wallpaper_thumbnail(appearance, Ok(handle));
|
||||
}
|
||||
@@ -596,7 +652,11 @@ mod tests {
|
||||
1.25,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
let preview = if page == Page::Wallpaper { "wallpaper" } else { "theme" };
|
||||
let preview = if page == Page::Wallpaper {
|
||||
"wallpaper"
|
||||
} else {
|
||||
"theme"
|
||||
};
|
||||
let path = format!("/tmp/ferese-{preview}-{backend}-{name}.png");
|
||||
image::save_buffer(&path, &pixels, 1250, 975, image::ColorType::Rgba8).unwrap();
|
||||
eprintln!("Preview page: {path}");
|
||||
@@ -617,13 +677,17 @@ mod tests {
|
||||
assert_eq!(snapshot.string("theme.background.path", ""), "/shared.png");
|
||||
assert!(snapshot.item("theme.light.background.path").is_none());
|
||||
assert!(snapshot.item("theme.dark.background.path").is_none());
|
||||
assert_eq!(snapshot.string("theme.background.lock_path", ""), "/lock.png");
|
||||
assert_eq!(
|
||||
snapshot.string("theme.background.lock_path", ""),
|
||||
"/lock.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_one_mode_preserves_the_other_and_inherits_shared_settings() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("theme { background { path \"/shared.png\"; mode \"fit\"; }; }".into()).unwrap();
|
||||
Snapshot::parse("theme { background { path \"/shared.png\"; mode \"fit\"; }; }".into())
|
||||
.unwrap();
|
||||
for edit in edits(Target::Light, "path", "/light.png") {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
@@ -641,11 +705,18 @@ mod tests {
|
||||
}
|
||||
|
||||
for target in Target::ALL {
|
||||
assert!(snapshot.item(&format!("{}.path", target.prefix())).is_none());
|
||||
assert!(
|
||||
snapshot
|
||||
.item(&format!("{}.path", target.prefix()))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(snapshot.string("theme.background.mode", ""), "fit");
|
||||
assert_eq!(snapshot.string("theme.background.lock_path", ""), "/lock.png");
|
||||
assert_eq!(
|
||||
snapshot.string("theme.background.lock_path", ""),
|
||||
"/lock.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,9 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
let path = match std::path::absolute(path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -31,7 +33,9 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
let mut watcher = match watcher {
|
||||
Ok(watcher) => watcher,
|
||||
Err(error) => {
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -46,7 +50,9 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
.map_err(notify::Error::io)
|
||||
.and_then(|()| watcher.watch(directory, RecursiveMode::NonRecursive));
|
||||
if let Err(error) = watch {
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +62,11 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
let snapshot = tokio::task::spawn_blocking(move || Snapshot::read(&file))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
if output.send(Message::ExternalConfig(snapshot)).await.is_err() {
|
||||
if output
|
||||
.send(Message::ExternalConfig(snapshot))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if events.recv().await.is_none() {
|
||||
@@ -90,7 +100,10 @@ mod tests {
|
||||
let path = directory.path().join("ferese/config.kdl");
|
||||
let stream = changes(&path);
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(Message::ExternalConfig(Ok(_)))
|
||||
));
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let source = "animations { speed 0.75; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
@@ -117,7 +130,10 @@ mod tests {
|
||||
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
|
||||
let stream = changes(&path);
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(Message::ExternalConfig(Ok(_)))
|
||||
));
|
||||
for source in ["bad [", "animations {\n speed 0.75\n}\n"] {
|
||||
let temporary = directory.path().join("replacement");
|
||||
std::fs::write(&temporary, source).unwrap();
|
||||
|
||||
Reference in new issue
Block a user