Per-skärm-inställningar med skärmväljare, kant-i-kant-marginaler
All checks were successful
check / check (push) Successful in 1m39s
All checks were successful
check / check (push) Successful in 1m39s
- outputs{} är nu generiska JSON-överridar: vilket configfält som helst
kan sättas per skärm och deep-mergas över den globala configen
(Config::for_output). Docka, autohide, dodge, beteende, hotspots och
CSS (scopat på window.o-<connector>) läser skärmens effektiva config.
- Settings har skärmväljare överst: klickbar förhandsgranskning av
skärmarna + deras barer, 'Alla skärmar' (standard) eller enskild
skärm. Bara ändrade fält sparas som override (json-diff);
återställ-knapp tar bort skärmens överridar. * = har egna.
- Marginaler: skuggutrymmet läggs inte längre på förankrade sidor så
0 = kant i kant; minusvärden tillåts (A ner till -64, B/C -256).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
22
doc/usage.md
22
doc/usage.md
@@ -35,6 +35,13 @@ Alla ändringar i filen slår igenom **live** — ingen omstart behövs.
|
||||
Högerklick på tom yta → **Inställningar…** Ändringar skrivs direkt till
|
||||
JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt.
|
||||
|
||||
Överst finns **skärmväljaren** med en klickbar förhandsgranskning av
|
||||
skärmarna och deras barer. "Alla skärmar" (standard) redigerar den
|
||||
globala configen; välj en skärm för att ge just den egna inställningar —
|
||||
bara fälten du ändrar sparas som override (`outputs` i JSON:en), resten
|
||||
följer globala. `*` i förhandsgranskningen = skärmen har egna
|
||||
inställningar; "Återställ skärmen till globalt" tar bort dem.
|
||||
|
||||
## Configreferens (`config.json`)
|
||||
|
||||
```jsonc
|
||||
@@ -42,9 +49,11 @@ JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt.
|
||||
"position": "bottom", // bottom | top | left | right
|
||||
"alignment": "center", // center | start | end — var längs kanten ön ligger
|
||||
"expand": false, // true = täck hela kanten (panelen fyller bredden/höjden)
|
||||
"margin": 8, // A: px från kanten baren ligger mot
|
||||
"margin": 8, // A: px från kanten baren ligger mot (minus = utanför)
|
||||
"margin_start": 0, // B: marginal i början av kanten (vänster/topp)
|
||||
"margin_end": 0, // C: marginal i slutet av kanten (höger/botten)
|
||||
// skuggutrymme läggs aldrig på förankrade sidor,
|
||||
// så 0 = verkligen kant i kant
|
||||
"layer": "top", // top | overlay | bottom (always-on-top = overlay)
|
||||
"avoid_windows": false, // true = reservera plats (exclusive zone)
|
||||
|
||||
@@ -81,11 +90,16 @@ JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt.
|
||||
"pins": ["firefox", "org.kde.konsole", "org.kde.dolphin"],
|
||||
"special_buttons": { "show_desktop": true, "overview": true },
|
||||
|
||||
"outputs": { // per-skärm-överstyrning (nyckel = connector)
|
||||
"outputs": { // per-skärm-överridar (nyckel = connector).
|
||||
// VILKET configfält som helst kan läggas här och gäller då bara den
|
||||
// skärmen (deep-merge över globala). Extranycklar: "enabled" och
|
||||
// "only_this_output" (visa bara fönster på den här skärmen).
|
||||
"DP-1": {
|
||||
"enabled": true,
|
||||
"only_this_output": true, // visa bara fönster på den här skärmen
|
||||
"pins": ["steam", "discord"] // null/utelämnad = ärver globala pins
|
||||
"only_this_output": true,
|
||||
"position": "left",
|
||||
"pins": ["steam", "discord"],
|
||||
"appearance": { "accent": "#5fb878" }
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -197,13 +197,10 @@ impl Default for Behavior {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct OutputConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub only_this_output: Option<bool>,
|
||||
pub pins: Option<Vec<String>>,
|
||||
}
|
||||
// Per-skärm-överridar: en partiell Config i JSON-form som deep-mergas
|
||||
// över den globala. Extranycklarna "enabled" och "only_this_output" är
|
||||
// skärm-specifika och läses separat (de finns inte i Config).
|
||||
pub type OutputOverride = serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -316,7 +313,7 @@ pub struct Config {
|
||||
pub context_menu: Vec<MenuItem>,
|
||||
pub pins: Vec<String>,
|
||||
pub special_buttons: SpecialButtons,
|
||||
pub outputs: HashMap<String, OutputConfig>,
|
||||
pub outputs: HashMap<String, OutputOverride>,
|
||||
pub autohide: Autohide,
|
||||
pub hotspots: Hotspots,
|
||||
pub log: LogConfig,
|
||||
@@ -356,26 +353,94 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-merge: värden i `patch` skrivs in i `base` (objekt rekursivt,
|
||||
/// allt annat ersätts).
|
||||
pub fn json_merge(base: &mut serde_json::Value, patch: &serde_json::Value) {
|
||||
if let (Some(base_map), Some(patch_map)) = (base.as_object_mut(), patch.as_object()) {
|
||||
for (k, v) in patch_map {
|
||||
match base_map.get_mut(k) {
|
||||
Some(slot) if slot.is_object() && v.is_object() => json_merge(slot, v),
|
||||
_ => {
|
||||
base_map.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*base = patch.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal diff: objektet med bara de löv som skiljer `after` från `before`.
|
||||
/// Null om inget skiljer.
|
||||
pub fn json_diff(before: &serde_json::Value, after: &serde_json::Value) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
if before == after {
|
||||
return Value::Null;
|
||||
}
|
||||
if let (Some(b), Some(a)) = (before.as_object(), after.as_object()) {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, av) in a {
|
||||
match b.get(k) {
|
||||
Some(bv) => {
|
||||
let d = json_diff(bv, av);
|
||||
if !d.is_null() {
|
||||
out.insert(k.clone(), d);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k.clone(), av.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::Object(out)
|
||||
}
|
||||
} else {
|
||||
after.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Effektiva pins för en viss skärm (output-överstyrning eller globala).
|
||||
pub fn pins_for(&self, output: &str) -> &[String] {
|
||||
self.outputs
|
||||
.get(output)
|
||||
.and_then(|o| o.pins.as_deref())
|
||||
.unwrap_or(&self.pins)
|
||||
/// Effektiv config för en skärm: global + per-skärm-överridar mergade.
|
||||
pub fn for_output(&self, output: &str) -> Config {
|
||||
match self.outputs.get(output) {
|
||||
None => self.clone(),
|
||||
Some(over) => {
|
||||
let mut base = serde_json::to_value(self).expect("config serialiserbar");
|
||||
json_merge(&mut base, over);
|
||||
match serde_json::from_value::<Config>(base) {
|
||||
Ok(mut cfg) => {
|
||||
// undvik rekursiva överridar
|
||||
cfg.outputs = self.outputs.clone();
|
||||
cfg
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("trasig override för {output} ({e}) — kör globalt");
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effektiva pins för en viss skärm.
|
||||
pub fn pins_for(&self, output: &str) -> Vec<String> {
|
||||
self.for_output(output).pins
|
||||
}
|
||||
|
||||
pub fn output_enabled(&self, output: &str) -> bool {
|
||||
self.outputs
|
||||
.get(output)
|
||||
.and_then(|o| o.enabled)
|
||||
.and_then(|o| o["enabled"].as_bool())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn only_this_output(&self, output: &str) -> bool {
|
||||
self.outputs
|
||||
.get(output)
|
||||
.and_then(|o| o.only_this_output)
|
||||
.and_then(|o| o["only_this_output"].as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
112
src/dock.rs
112
src/dock.rs
@@ -16,8 +16,8 @@ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||
use log::{debug, info};
|
||||
|
||||
use crate::config::{
|
||||
Alignment, AutohideMode, ClickAction, GroupPopup, Layer as CfgLayer, MiddleClick,
|
||||
Position, ScrollAction, SharedConfig,
|
||||
Alignment, AutohideMode, ClickAction, Config, GroupPopup, Layer as CfgLayer,
|
||||
MiddleClick, Position, ScrollAction, SharedConfig,
|
||||
};
|
||||
use crate::icons::{self, AppMeta};
|
||||
use crate::menu;
|
||||
@@ -90,8 +90,7 @@ fn side_edges(pos: &Position) -> (Edge, Edge) {
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_layer(win: >k::ApplicationWindow, ctx: &Ctx, monitor: &gdk::Monitor) {
|
||||
let cfg = ctx.cfg.borrow();
|
||||
fn configure_layer(win: >k::ApplicationWindow, cfg: &Config, monitor: &gdk::Monitor) {
|
||||
win.init_layer_shell();
|
||||
win.set_namespace(Some("waydock"));
|
||||
win.set_monitor(Some(monitor));
|
||||
@@ -167,9 +166,10 @@ pub fn rebuild_all(ctx: &Rc<Ctx>) {
|
||||
}
|
||||
|
||||
fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Dock> {
|
||||
let cfg = ctx.cfg.borrow();
|
||||
let cfg = ctx.cfg.borrow().for_output(&connector);
|
||||
let win = gtk::ApplicationWindow::new(&ctx.app);
|
||||
win.add_css_class("waydock");
|
||||
win.add_css_class(&crate::style::output_class(&connector));
|
||||
|
||||
let orient = orientation(&cfg.position);
|
||||
let panel = gtk::Box::new(orient, 0);
|
||||
@@ -199,17 +199,46 @@ fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Do
|
||||
}
|
||||
panel.append(&items_box);
|
||||
|
||||
// yttre marginal = plats för skuggan att rita
|
||||
// yttre marginal = plats för skuggan att rita — men INTE på sidor som
|
||||
// är förankrade mot skärmkanten, så att margin = 0 verkligen når kanten
|
||||
let outer = gtk::Box::new(orient, 0);
|
||||
let pad = if cfg.appearance.shadow.enabled {
|
||||
cfg.appearance.shadow.blur as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
outer.set_margin_top(pad);
|
||||
outer.set_margin_bottom(pad);
|
||||
outer.set_margin_start(pad);
|
||||
outer.set_margin_end(pad);
|
||||
let anchored_start = cfg.expand || cfg.alignment == Alignment::Start;
|
||||
let anchored_end = cfg.expand || cfg.alignment == Alignment::End;
|
||||
let (top, bottom, start, end) = match cfg.position {
|
||||
Position::Bottom => (
|
||||
pad,
|
||||
0,
|
||||
if anchored_start { 0 } else { pad },
|
||||
if anchored_end { 0 } else { pad },
|
||||
),
|
||||
Position::Top => (
|
||||
0,
|
||||
pad,
|
||||
if anchored_start { 0 } else { pad },
|
||||
if anchored_end { 0 } else { pad },
|
||||
),
|
||||
Position::Left => (
|
||||
if anchored_start { 0 } else { pad },
|
||||
if anchored_end { 0 } else { pad },
|
||||
0,
|
||||
pad,
|
||||
),
|
||||
Position::Right => (
|
||||
if anchored_start { 0 } else { pad },
|
||||
if anchored_end { 0 } else { pad },
|
||||
pad,
|
||||
0,
|
||||
),
|
||||
};
|
||||
outer.set_margin_top(top);
|
||||
outer.set_margin_bottom(bottom);
|
||||
outer.set_margin_start(start);
|
||||
outer.set_margin_end(end);
|
||||
outer.append(&panel);
|
||||
|
||||
// revealern ger autohide-animationen
|
||||
@@ -229,9 +258,8 @@ fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Do
|
||||
revealer.set_child(Some(&outer));
|
||||
win.set_child(Some(&revealer));
|
||||
let autohide_enabled = cfg.autohide.enabled;
|
||||
drop(cfg);
|
||||
|
||||
configure_layer(&win, ctx, monitor);
|
||||
configure_layer(&win, &cfg, monitor);
|
||||
menu::setup_dock_actions(ctx, &win);
|
||||
|
||||
let dock = Rc::new(Dock {
|
||||
@@ -289,7 +317,7 @@ fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Do
|
||||
|
||||
/// Den tunna remsan vid skärmkanten som väcker en gömd dock.
|
||||
fn build_sensor(ctx: &Rc<Ctx>, dock: &Rc<Dock>, monitor: &gdk::Monitor) {
|
||||
let cfg = ctx.cfg.borrow();
|
||||
let cfg = ctx.cfg.borrow().for_output(&dock.connector);
|
||||
let sensor = gtk::ApplicationWindow::new(&ctx.app);
|
||||
sensor.add_css_class("waydock-sensor");
|
||||
sensor.init_layer_shell();
|
||||
@@ -359,7 +387,7 @@ fn set_dock_hidden(dock: &Rc<Dock>, hidden: bool) {
|
||||
}
|
||||
|
||||
fn should_hide(ctx: &Ctx, dock: &Dock) -> bool {
|
||||
let cfg = ctx.cfg.borrow();
|
||||
let cfg = ctx.cfg.borrow().for_output(&dock.connector);
|
||||
if !cfg.autohide.enabled || dock.hover.get() {
|
||||
return false;
|
||||
}
|
||||
@@ -383,7 +411,7 @@ pub fn update_autohide(ctx: &Rc<Ctx>) {
|
||||
}
|
||||
|
||||
fn schedule_hide(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
|
||||
let delay = ctx.cfg.borrow().autohide.hide_delay_ms;
|
||||
let delay = ctx.cfg.borrow().for_output(&dock.connector).autohide.hide_delay_ms;
|
||||
let generation = dock.hide_generation.get() + 1;
|
||||
dock.hide_generation.set(generation);
|
||||
let ctx = ctx.clone();
|
||||
@@ -397,18 +425,24 @@ fn schedule_hide(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
|
||||
|
||||
/// Räkna om dodge-överlapp för alla dockor via Wayfire-IPC-geometri.
|
||||
pub fn recompute_dodge(ctx: &Rc<Ctx>) {
|
||||
{
|
||||
let cfg = ctx.cfg.borrow();
|
||||
if !cfg.autohide.enabled || cfg.autohide.mode != AutohideMode::Dodge {
|
||||
return;
|
||||
}
|
||||
// hämta bara geometri om någon dock alls kör dodge
|
||||
let any_dodge = ctx.docks.borrow().iter().any(|d| {
|
||||
let c = ctx.cfg.borrow().for_output(&d.connector);
|
||||
c.autohide.enabled && c.autohide.mode == AutohideMode::Dodge
|
||||
});
|
||||
if !any_dodge {
|
||||
return;
|
||||
}
|
||||
let Some(views) = wayfire_ipc::list_views() else { return };
|
||||
let Some(outputs) = wayfire_ipc::list_outputs() else { return };
|
||||
let position = ctx.cfg.borrow().position.clone();
|
||||
let margin = ctx.cfg.borrow().margin;
|
||||
|
||||
for dock in ctx.docks.borrow().iter() {
|
||||
let eff = ctx.cfg.borrow().for_output(&dock.connector);
|
||||
if !eff.autohide.enabled || eff.autohide.mode != AutohideMode::Dodge {
|
||||
dock.dodge_overlap.set(false);
|
||||
continue;
|
||||
}
|
||||
let (position, margin) = (eff.position.clone(), eff.margin);
|
||||
let Some(output) = outputs
|
||||
.iter()
|
||||
.find(|o| o["name"].as_str() == Some(dock.connector.as_str()))
|
||||
@@ -542,7 +576,11 @@ fn special_button(icon_name: &str, tooltip: &str) -> gtk::Button {
|
||||
}
|
||||
|
||||
fn add_special_buttons(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
|
||||
let specials = ctx.cfg.borrow().special_buttons.clone();
|
||||
let specials = ctx
|
||||
.cfg
|
||||
.borrow()
|
||||
.for_output(&dock.connector)
|
||||
.special_buttons;
|
||||
|
||||
if specials.overview && wayfire_ipc::available() {
|
||||
let btn = special_button("view-grid-symbolic", "Översikt (scale)");
|
||||
@@ -562,12 +600,11 @@ fn add_special_buttons(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
|
||||
}
|
||||
|
||||
fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::Button {
|
||||
let cfg = ctx.cfg.borrow();
|
||||
let cfg = ctx.cfg.borrow().for_output(connector);
|
||||
let icon_size = cfg.appearance.icon_size;
|
||||
let max_dots = cfg.appearance.indicator.max as usize;
|
||||
let tooltips = cfg.behavior.tooltips;
|
||||
let urgent_pulse = cfg.behavior.urgent_pulse;
|
||||
drop(cfg);
|
||||
|
||||
let btn = gtk::Button::new();
|
||||
btn.add_css_class("dock-item");
|
||||
@@ -639,8 +676,8 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
|
||||
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||
let live = live_item(&ctx, &conn2, &item);
|
||||
match gesture.current_button() {
|
||||
1 => primary_action(&ctx, &live, &btn2),
|
||||
2 => middle_action(&ctx, &live),
|
||||
1 => primary_action(&ctx, &conn2, &live, &btn2),
|
||||
2 => middle_action(&ctx, &conn2, &live),
|
||||
3 => menu::popup_item_menu(&ctx, &live, &btn2),
|
||||
_ => {}
|
||||
}
|
||||
@@ -649,9 +686,7 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
|
||||
btn.add_controller(click);
|
||||
|
||||
// hover-popup för grupperade appar (om valt i configen)
|
||||
if item.windows.len() > 1
|
||||
&& ctx.cfg.borrow().behavior.group_popup == GroupPopup::Hover
|
||||
{
|
||||
if item.windows.len() > 1 && cfg.behavior.group_popup == GroupPopup::Hover {
|
||||
let hover_ctl = gtk::EventControllerMotion::new();
|
||||
let ctx2 = ctx.clone();
|
||||
let item2 = item.clone();
|
||||
@@ -682,7 +717,7 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
|
||||
let item = item.clone();
|
||||
let conn2 = connector.to_string();
|
||||
scroll.connect_scroll(move |_, _, dy| {
|
||||
if ctx.cfg.borrow().behavior.scroll == ScrollAction::CycleWindows {
|
||||
if ctx.cfg.borrow().for_output(&conn2).behavior.scroll == ScrollAction::CycleWindows {
|
||||
let live = live_item(&ctx, &conn2, &item);
|
||||
cycle_windows(&ctx, &live, dy > 0.0);
|
||||
glib::Propagation::Stop
|
||||
@@ -696,28 +731,25 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
|
||||
btn
|
||||
}
|
||||
|
||||
fn primary_action(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: >k::Button) {
|
||||
fn primary_action(ctx: &Rc<Ctx>, connector: &str, item: &Rc<Item>, anchor: >k::Button) {
|
||||
let service = ctx.service.borrow();
|
||||
let Some(service) = service.as_ref() else {
|
||||
icons::launch(&item.meta);
|
||||
return;
|
||||
};
|
||||
let behavior = ctx.cfg.borrow().for_output(connector).behavior;
|
||||
match item.windows.len() {
|
||||
0 => icons::launch(&item.meta),
|
||||
1 => {
|
||||
let w = &item.windows[0];
|
||||
let click = ctx.cfg.borrow().behavior.click.clone();
|
||||
if w.activated && !w.minimized && click == ClickAction::FocusOrMinimize {
|
||||
if w.activated && !w.minimized && behavior.click == ClickAction::FocusOrMinimize {
|
||||
service.set_minimized(w, true);
|
||||
} else {
|
||||
service.activate(w);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (click, popup) = {
|
||||
let b = &ctx.cfg.borrow().behavior;
|
||||
(b.click.clone(), b.group_popup.clone())
|
||||
};
|
||||
let (click, popup) = (behavior.click.clone(), behavior.group_popup.clone());
|
||||
if click == ClickAction::Cycle || popup == GroupPopup::Hover {
|
||||
cycle_windows(ctx, item, true);
|
||||
} else {
|
||||
@@ -727,8 +759,8 @@ fn primary_action(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: >k::Button) {
|
||||
}
|
||||
}
|
||||
|
||||
fn middle_action(ctx: &Rc<Ctx>, item: &Rc<Item>) {
|
||||
let action = ctx.cfg.borrow().behavior.middle_click.clone();
|
||||
fn middle_action(ctx: &Rc<Ctx>, connector: &str, item: &Rc<Item>) {
|
||||
let action = ctx.cfg.borrow().for_output(connector).behavior.middle_click.clone();
|
||||
match action {
|
||||
MiddleClick::LaunchNew => icons::launch(&item.meta),
|
||||
MiddleClick::Close => {
|
||||
|
||||
@@ -109,10 +109,25 @@ thread_local! {
|
||||
/// Bygg om alla hotspot-ytor enligt configen (körs vid start + configbyte).
|
||||
/// Idempotent: oförändrad hotspot-config + skärmuppsättning → ingen ombyggnad.
|
||||
pub fn rebuild(ctx: &Rc<Ctx>) {
|
||||
let n_monitors = gdk::Display::default()
|
||||
.map(|d| d.monitors().n_items())
|
||||
.unwrap_or(0);
|
||||
let signature = format!("{:?}|{n_monitors}", ctx.cfg.borrow().hotspots);
|
||||
let display = gdk::Display::default().expect("ingen display");
|
||||
let monitors = display.monitors();
|
||||
|
||||
// signatur = alla skärmars EFFEKTIVA hotspot-config
|
||||
let mut signature = String::new();
|
||||
let mut mons: Vec<(String, gdk::Monitor)> = Vec::new();
|
||||
for i in 0..monitors.n_items() {
|
||||
let Some(monitor) = monitors.item(i).and_then(|o| o.downcast::<gdk::Monitor>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let connector = monitor
|
||||
.connector()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| format!("monitor-{i}"));
|
||||
let eff = ctx.cfg.borrow().for_output(&connector);
|
||||
signature += &format!("{connector}:{:?}|", eff.hotspots);
|
||||
mons.push((connector, monitor));
|
||||
}
|
||||
let unchanged = LAST_SIGNATURE.with(|s| *s.borrow() == signature);
|
||||
if unchanged {
|
||||
return;
|
||||
@@ -123,41 +138,33 @@ pub fn rebuild(ctx: &Rc<Ctx>) {
|
||||
win.destroy();
|
||||
}
|
||||
|
||||
let cfg = ctx.cfg.borrow();
|
||||
let dwell = cfg.hotspots.dwell_ms;
|
||||
let c = cfg.hotspots.corners.clone();
|
||||
let e = cfg.hotspots.edges.clone();
|
||||
drop(cfg);
|
||||
|
||||
let corner_defs: [(&Option<HotspotAction>, [Edge; 2]); 4] = [
|
||||
(&c.top_left, [Edge::Top, Edge::Left]),
|
||||
(&c.top_right, [Edge::Top, Edge::Right]),
|
||||
(&c.bottom_left, [Edge::Bottom, Edge::Left]),
|
||||
(&c.bottom_right, [Edge::Bottom, Edge::Right]),
|
||||
];
|
||||
// kanterna lämnar hörnen fria (hörn-ytor ligger ovanpå ändå — overlay
|
||||
// med senare mappade ytor hamnar överst, så det kvittar i praktiken)
|
||||
#[allow(clippy::type_complexity)]
|
||||
let edge_defs: [(&Option<HotspotAction>, [Edge; 3], (i32, i32)); 4] = [
|
||||
(&e.top, [Edge::Top, Edge::Left, Edge::Right], (-1, EDGE_PX)),
|
||||
(&e.bottom, [Edge::Bottom, Edge::Left, Edge::Right], (-1, EDGE_PX)),
|
||||
(&e.left, [Edge::Left, Edge::Top, Edge::Bottom], (EDGE_PX, -1)),
|
||||
(&e.right, [Edge::Right, Edge::Top, Edge::Bottom], (EDGE_PX, -1)),
|
||||
];
|
||||
|
||||
let display = gdk::Display::default().expect("ingen display");
|
||||
let monitors = display.monitors();
|
||||
let mut windows = Vec::new();
|
||||
for i in 0..monitors.n_items() {
|
||||
let Some(monitor) = monitors.item(i).and_then(|o| o.downcast::<gdk::Monitor>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (connector, monitor) in &mons {
|
||||
let h = ctx.cfg.borrow().for_output(connector).hotspots;
|
||||
let dwell = h.dwell_ms;
|
||||
let c = h.corners.clone();
|
||||
let e = h.edges.clone();
|
||||
|
||||
let corner_defs: [(&Option<HotspotAction>, [Edge; 2]); 4] = [
|
||||
(&c.top_left, [Edge::Top, Edge::Left]),
|
||||
(&c.top_right, [Edge::Top, Edge::Right]),
|
||||
(&c.bottom_left, [Edge::Bottom, Edge::Left]),
|
||||
(&c.bottom_right, [Edge::Bottom, Edge::Right]),
|
||||
];
|
||||
// kanterna lämnar hörnen fria (hörn-ytor ligger ovanpå ändå)
|
||||
#[allow(clippy::type_complexity)]
|
||||
let edge_defs: [(&Option<HotspotAction>, [Edge; 3], (i32, i32)); 4] = [
|
||||
(&e.top, [Edge::Top, Edge::Left, Edge::Right], (-1, EDGE_PX)),
|
||||
(&e.bottom, [Edge::Bottom, Edge::Left, Edge::Right], (-1, EDGE_PX)),
|
||||
(&e.left, [Edge::Left, Edge::Top, Edge::Bottom], (EDGE_PX, -1)),
|
||||
(&e.right, [Edge::Right, Edge::Top, Edge::Bottom], (EDGE_PX, -1)),
|
||||
];
|
||||
|
||||
for (action, edges) in &corner_defs {
|
||||
if let Some(a) = action {
|
||||
windows.push(build_hotspot(
|
||||
ctx,
|
||||
&monitor,
|
||||
monitor,
|
||||
edges,
|
||||
(CORNER_PX, CORNER_PX),
|
||||
a.clone(),
|
||||
@@ -167,7 +174,7 @@ pub fn rebuild(ctx: &Rc<Ctx>) {
|
||||
}
|
||||
for (action, edges, size) in &edge_defs {
|
||||
if let Some(a) = action {
|
||||
windows.push(build_hotspot(ctx, &monitor, edges, *size, a.clone(), dwell));
|
||||
windows.push(build_hotspot(ctx, monitor, edges, *size, a.clone(), dwell));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ fn layout_signature(cfg: &config::Config) -> String {
|
||||
cfg.avoid_windows,
|
||||
cfg.appearance.shadow.enabled,
|
||||
cfg.appearance.shadow.blur,
|
||||
cfg.outputs.keys().collect::<Vec<_>>(),
|
||||
serde_json::to_string(&cfg.outputs).unwrap_or_default(),
|
||||
) + &format!("|{:?}", cfg.autohide)
|
||||
}
|
||||
|
||||
|
||||
29
src/menu.rs
29
src/menu.rs
@@ -123,24 +123,27 @@ fn toggle_pin(ctx: &Rc<Ctx>, connector: &str) {
|
||||
};
|
||||
{
|
||||
let mut cfg = ctx.cfg.borrow_mut();
|
||||
// skriv i output-egna pins om skärmen har egna, annars i de globala
|
||||
// skriv i skärmens pin-override om den har en, annars i de globala
|
||||
let has_own = cfg
|
||||
.outputs
|
||||
.get(connector)
|
||||
.map(|o| o.pins.is_some())
|
||||
.map(|o| o["pins"].is_array())
|
||||
.unwrap_or(false);
|
||||
let pins: &mut Vec<String> = if has_own {
|
||||
cfg.outputs
|
||||
.get_mut(connector)
|
||||
.and_then(|o| o.pins.as_mut())
|
||||
.expect("has_own kollades nyss")
|
||||
} else {
|
||||
&mut cfg.pins
|
||||
let mutate = |pins: &mut Vec<String>| {
|
||||
if pinned {
|
||||
pins.retain(|p| p.to_lowercase() != key);
|
||||
} else if !pins.iter().any(|p| p.to_lowercase() == key) {
|
||||
pins.push(key.clone());
|
||||
}
|
||||
};
|
||||
if pinned {
|
||||
pins.retain(|p| p.to_lowercase() != key);
|
||||
} else if !pins.iter().any(|p| p.to_lowercase() == key) {
|
||||
pins.push(key.clone());
|
||||
if has_own {
|
||||
let over = cfg.outputs.get_mut(connector).expect("has_own kollades nyss");
|
||||
let mut pins: Vec<String> =
|
||||
serde_json::from_value(over["pins"].clone()).unwrap_or_default();
|
||||
mutate(&mut pins);
|
||||
over["pins"] = serde_json::to_value(pins).expect("strängar serialiserbara");
|
||||
} else {
|
||||
mutate(&mut cfg.pins);
|
||||
}
|
||||
if let Err(e) = config::save(&ctx.cfg_path, &cfg) {
|
||||
warn!("kunde inte spara config: {e}");
|
||||
|
||||
345
src/settings.rs
345
src/settings.rs
@@ -24,14 +24,58 @@ thread_local! {
|
||||
const { std::cell::RefCell::new(None) };
|
||||
static ILLUSTRATION: std::cell::RefCell<Option<glib::WeakRef<gtk::DrawingArea>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
static PREVIEW: std::cell::RefCell<Option<glib::WeakRef<gtk::DrawingArea>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
/// None = "Alla skärmar" (globala configen); Some = en enskild skärm
|
||||
static SCOPE: std::cell::RefCell<Option<String>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn current_scope() -> Option<String> {
|
||||
SCOPE.with(|s| s.borrow().clone())
|
||||
}
|
||||
|
||||
/// Configen som settings-sidorna ska visa: global eller skärmens effektiva.
|
||||
fn scoped_config(ctx: &Rc<Ctx>) -> config::Config {
|
||||
match current_scope() {
|
||||
None => ctx.cfg.borrow().clone(),
|
||||
Some(conn) => ctx.cfg.borrow().for_output(&conn),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppdatera config + applicera + spara (debouncat 300 ms).
|
||||
/// Med en skärm vald skrivs bara de ändrade fälten in som override för
|
||||
/// den skärmen; "Alla skärmar" redigerar den globala configen.
|
||||
fn commit(ctx: &Rc<Ctx>, mutate: impl FnOnce(&mut config::Config)) {
|
||||
mutate(&mut ctx.cfg.borrow_mut());
|
||||
match current_scope() {
|
||||
None => mutate(&mut ctx.cfg.borrow_mut()),
|
||||
Some(conn) => {
|
||||
let mut eff = ctx.cfg.borrow().for_output(&conn);
|
||||
let before = serde_json::to_value(&eff).expect("config serialiserbar");
|
||||
mutate(&mut eff);
|
||||
let after = serde_json::to_value(&eff).expect("config serialiserbar");
|
||||
let mut patch = config::json_diff(&before, &after);
|
||||
if let Some(obj) = patch.as_object_mut() {
|
||||
obj.remove("outputs"); // aldrig rekursiva överridar
|
||||
}
|
||||
if !patch.is_null() {
|
||||
let mut cfg = ctx.cfg.borrow_mut();
|
||||
let entry = cfg
|
||||
.outputs
|
||||
.entry(conn)
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
config::json_merge(entry, &patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cb) = ctx.on_config_changed.borrow().as_ref() {
|
||||
cb();
|
||||
}
|
||||
PREVIEW.with(|i| {
|
||||
if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||||
da.queue_draw();
|
||||
}
|
||||
});
|
||||
ILLUSTRATION.with(|i| {
|
||||
if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||||
da.queue_draw();
|
||||
@@ -221,7 +265,7 @@ fn hotspot_entry(
|
||||
|
||||
fn build_appearance_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
let (scroll, list) = page();
|
||||
let a = ctx.cfg.borrow().appearance.clone();
|
||||
let a = scoped_config(ctx).appearance;
|
||||
|
||||
heading(&list, "Panel");
|
||||
row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v));
|
||||
@@ -267,7 +311,7 @@ fn margin_illustration(ctx: &Rc<Ctx>) -> gtk::DrawingArea {
|
||||
da.set_halign(gtk::Align::Center);
|
||||
let ctx2 = ctx.clone();
|
||||
da.set_draw_func(move |_, cr, w, h| {
|
||||
let cfg = ctx2.cfg.borrow();
|
||||
let cfg = scoped_config(&ctx2);
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
|
||||
// "skärmen"
|
||||
@@ -381,7 +425,7 @@ fn margin_illustration(ctx: &Rc<Ctx>) -> gtk::DrawingArea {
|
||||
|
||||
fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
let (scroll, list) = page();
|
||||
let cfg = ctx.cfg.borrow().clone();
|
||||
let cfg = scoped_config(ctx);
|
||||
|
||||
heading(&list, "Placering");
|
||||
let pos_idx = match cfg.position {
|
||||
@@ -427,9 +471,9 @@ fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
|
||||
heading(&list, "Marginaler");
|
||||
list.append(&margin_illustration(ctx));
|
||||
row(&list, "A — Avstånd från kanten", &spin(ctx, cfg.margin as f64, (0.0, 128.0), 1.0, |c, v| c.margin = v as i32));
|
||||
row(&list, "B — Marginal start", &spin(ctx, cfg.margin_start as f64, (0.0, 512.0), 1.0, |c, v| c.margin_start = v as i32));
|
||||
row(&list, "C — Marginal slut", &spin(ctx, cfg.margin_end as f64, (0.0, 512.0), 1.0, |c, v| c.margin_end = v as i32));
|
||||
row(&list, "A — Avstånd från kanten", &spin(ctx, cfg.margin as f64, (-64.0, 256.0), 1.0, |c, v| c.margin = v as i32));
|
||||
row(&list, "B — Marginal start", &spin(ctx, cfg.margin_start as f64, (-256.0, 1024.0), 1.0, |c, v| c.margin_start = v as i32));
|
||||
row(&list, "C — Marginal slut", &spin(ctx, cfg.margin_end as f64, (-256.0, 1024.0), 1.0, |c, v| c.margin_end = v as i32));
|
||||
let note = gtk::Label::new(Some(
|
||||
"A = avstånd från kanten baren ligger mot.\nB/C = marginal i början/slutet av kanten — indrag när baren\ntäcker kanten, annars avstånd från vald sida (vid Start/Slut).",
|
||||
));
|
||||
@@ -461,8 +505,9 @@ fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
|
||||
fn build_behavior_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
let (scroll, list) = page();
|
||||
let b = ctx.cfg.borrow().behavior.clone();
|
||||
let sp = ctx.cfg.borrow().special_buttons.clone();
|
||||
let scoped = scoped_config(ctx);
|
||||
let b = scoped.behavior.clone();
|
||||
let sp = scoped.special_buttons.clone();
|
||||
|
||||
heading(&list, "Klick");
|
||||
let click_idx = match b.click {
|
||||
@@ -534,7 +579,7 @@ fn build_behavior_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
("Stäng", MenuItem::Close),
|
||||
];
|
||||
for (label, mi) in entries {
|
||||
let present = ctx.cfg.borrow().context_menu.contains(&mi);
|
||||
let present = scoped.context_menu.contains(&mi);
|
||||
let mi2 = mi.clone();
|
||||
row(&list, label, &switch(ctx, present, move |c, v| {
|
||||
if v {
|
||||
@@ -561,7 +606,7 @@ fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
while let Some(c) = pins_box.first_child() {
|
||||
pins_box.remove(&c);
|
||||
}
|
||||
let pins = ctx.cfg.borrow().pins.clone();
|
||||
let pins = scoped_config(ctx).pins.clone();
|
||||
for (i, pin) in pins.iter().enumerate() {
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
let name = crate::icons::lookup_pin(pin)
|
||||
@@ -592,7 +637,9 @@ fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
let pb3 = pins_box.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
commit(&ctx3, |c| {
|
||||
c.pins.remove(i);
|
||||
if i < c.pins.len() {
|
||||
c.pins.remove(i);
|
||||
}
|
||||
});
|
||||
rebuild_rows(&ctx3, &pb3);
|
||||
});
|
||||
@@ -643,7 +690,7 @@ fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
|
||||
fn build_hotspots_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
let (scroll, list) = page();
|
||||
let h = ctx.cfg.borrow().hotspots.clone();
|
||||
let h = scoped_config(ctx).hotspots.clone();
|
||||
|
||||
heading(&list, "Hot corners");
|
||||
row(&list, "Uppe vänster", &hotspot_entry(ctx, &h.corners.top_left, |c, v| c.hotspots.corners.top_left = v));
|
||||
@@ -668,6 +715,142 @@ fn build_hotspots_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
|
||||
scroll
|
||||
}
|
||||
|
||||
/// Skärmarna som gdk ser: (connector, logisk geometri).
|
||||
fn monitor_list() -> Vec<(String, gdk::Rectangle)> {
|
||||
let mut out = Vec::new();
|
||||
let Some(display) = gdk::Display::default() else {
|
||||
return out;
|
||||
};
|
||||
let monitors = display.monitors();
|
||||
for i in 0..monitors.n_items() {
|
||||
let Some(m) = monitors.item(i).and_then(|o| o.downcast::<gdk::Monitor>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let name = m
|
||||
.connector()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| format!("monitor-{i}"));
|
||||
out.push((name, m.geometry()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Normalisera skärmrektanglarna till förhandsgranskningens yta.
|
||||
fn preview_layout(mons: &[(String, gdk::Rectangle)], w: f64, h: f64)
|
||||
-> Vec<(String, (f64, f64, f64, f64))> {
|
||||
if mons.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let min_x = mons.iter().map(|(_, g)| g.x()).min().unwrap_or(0) as f64;
|
||||
let min_y = mons.iter().map(|(_, g)| g.y()).min().unwrap_or(0) as f64;
|
||||
let max_x = mons.iter().map(|(_, g)| g.x() + g.width()).max().unwrap_or(1) as f64;
|
||||
let max_y = mons.iter().map(|(_, g)| g.y() + g.height()).max().unwrap_or(1) as f64;
|
||||
let scale = ((w - 16.0) / (max_x - min_x)).min((h - 16.0) / (max_y - min_y));
|
||||
let off_x = (w - (max_x - min_x) * scale) / 2.0;
|
||||
let off_y = (h - (max_y - min_y) * scale) / 2.0;
|
||||
mons.iter()
|
||||
.map(|(name, g)| {
|
||||
(
|
||||
name.clone(),
|
||||
(
|
||||
off_x + (g.x() as f64 - min_x) * scale,
|
||||
off_y + (g.y() as f64 - min_y) * scale,
|
||||
g.width() as f64 * scale,
|
||||
g.height() as f64 * scale,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
type RefreshSlot = Rc<std::cell::RefCell<Option<Rc<dyn Fn()>>>>;
|
||||
|
||||
fn build_preview(ctx: &Rc<Ctx>, refresh: RefreshSlot) -> gtk::DrawingArea {
|
||||
let da = gtk::DrawingArea::new();
|
||||
da.set_content_width(400);
|
||||
da.set_content_height(96);
|
||||
da.set_halign(gtk::Align::Center);
|
||||
let ctx2 = ctx.clone();
|
||||
da.set_draw_func(move |_, cr, w, h| {
|
||||
let scope = current_scope();
|
||||
let mons = monitor_list();
|
||||
for (name, (x, y, mw, mh)) in preview_layout(&mons, w as f64, h as f64) {
|
||||
let eff = ctx2.cfg.borrow().for_output(&name);
|
||||
// skärmen
|
||||
cr.set_source_rgba(0.15, 0.15, 0.17, 1.0);
|
||||
cr.rectangle(x, y, mw, mh);
|
||||
let _ = cr.fill();
|
||||
let selected = scope.is_none() || scope.as_deref() == Some(name.as_str());
|
||||
if selected {
|
||||
cr.set_source_rgba(0.79, 0.33, 0.36, 1.0);
|
||||
cr.set_line_width(2.0);
|
||||
} else {
|
||||
cr.set_source_rgba(0.45, 0.45, 0.5, 1.0);
|
||||
cr.set_line_width(1.0);
|
||||
}
|
||||
cr.rectangle(x, y, mw, mh);
|
||||
let _ = cr.stroke();
|
||||
// barens mini-representation
|
||||
let horizontal = matches!(eff.position, Position::Top | Position::Bottom);
|
||||
let full = if horizontal { mw - 8.0 } else { mh - 8.0 };
|
||||
let len = if eff.expand { full } else { full * 0.45 };
|
||||
let along = match (eff.expand, &eff.alignment) {
|
||||
(true, _) | (false, Alignment::Center) => (full - len) / 2.0 + 4.0,
|
||||
(false, Alignment::Start) => 4.0,
|
||||
(false, Alignment::End) => full - len + 4.0,
|
||||
};
|
||||
cr.set_source_rgba(0.9, 0.9, 0.92, 0.95);
|
||||
let t = 3.0;
|
||||
match eff.position {
|
||||
Position::Bottom => cr.rectangle(x + along, y + mh - 6.0, len, t),
|
||||
Position::Top => cr.rectangle(x + along, y + 3.0, len, t),
|
||||
Position::Left => cr.rectangle(x + 3.0, y + along, t, len),
|
||||
Position::Right => cr.rectangle(x + mw - 6.0, y + along, t, len),
|
||||
}
|
||||
let _ = cr.fill();
|
||||
// namn + markering om skärmen har egna inställningar
|
||||
cr.set_source_rgba(0.85, 0.85, 0.88, 1.0);
|
||||
cr.set_font_size(10.0);
|
||||
cr.move_to(x + 5.0, y + 14.0);
|
||||
let has_override = ctx2.cfg.borrow().outputs.contains_key(&name);
|
||||
let text = if has_override { format!("{name} *") } else { name.clone() };
|
||||
let _ = cr.show_text(&text);
|
||||
}
|
||||
});
|
||||
|
||||
// klick väljer skärm (utanför = alla)
|
||||
let click = gtk::GestureClick::new();
|
||||
{
|
||||
let da2 = da.clone();
|
||||
click.connect_pressed(move |_, _, px, py| {
|
||||
let mons = monitor_list();
|
||||
let rects = preview_layout(&mons, da2.width() as f64, da2.height() as f64);
|
||||
let hit = rects
|
||||
.iter()
|
||||
.find(|(_, (x, y, w, h))| px >= *x && px <= x + w && py >= *y && py <= y + h)
|
||||
.map(|(n, _)| n.clone());
|
||||
SCOPE.with(|s| *s.borrow_mut() = hit);
|
||||
if let Some(f) = refresh.borrow().as_ref() {
|
||||
f();
|
||||
}
|
||||
});
|
||||
}
|
||||
da.add_controller(click);
|
||||
PREVIEW.with(|p| *p.borrow_mut() = Some(da.downgrade()));
|
||||
da
|
||||
}
|
||||
|
||||
fn build_notebook(ctx: &Rc<Ctx>) -> gtk::Notebook {
|
||||
let notebook = gtk::Notebook::new();
|
||||
notebook.set_vexpand(true);
|
||||
notebook.append_page(&build_appearance_page(ctx), Some(>k::Label::new(Some("Utseende"))));
|
||||
notebook.append_page(&build_layout_page(ctx), Some(>k::Label::new(Some("Layout"))));
|
||||
notebook.append_page(&build_behavior_page(ctx), Some(>k::Label::new(Some("Beteende"))));
|
||||
notebook.append_page(&build_pins_page(ctx), Some(>k::Label::new(Some("Pins"))));
|
||||
notebook.append_page(&build_hotspots_page(ctx), Some(>k::Label::new(Some("Hotspots"))));
|
||||
notebook
|
||||
}
|
||||
|
||||
/// Öppna settings-fönstret (återanvänder ett redan öppet).
|
||||
pub fn open(ctx: &Rc<Ctx>) {
|
||||
let existing = OPEN_WINDOW.with(|w| w.borrow().as_ref().and_then(|w| w.upgrade()));
|
||||
@@ -675,24 +858,136 @@ pub fn open(ctx: &Rc<Ctx>) {
|
||||
win.present();
|
||||
return;
|
||||
}
|
||||
SCOPE.with(|s| *s.borrow_mut() = None);
|
||||
|
||||
let win = gtk::Window::new();
|
||||
win.set_title(Some("waydock — inställningar"));
|
||||
win.set_icon_name(Some("waydock"));
|
||||
win.set_default_size(520, 620);
|
||||
win.set_default_size(560, 760);
|
||||
|
||||
let notebook = gtk::Notebook::new();
|
||||
notebook.append_page(&build_appearance_page(ctx), Some(>k::Label::new(Some("Utseende"))));
|
||||
notebook.append_page(&build_layout_page(ctx), Some(>k::Label::new(Some("Layout"))));
|
||||
notebook.append_page(&build_behavior_page(ctx), Some(>k::Label::new(Some("Beteende"))));
|
||||
notebook.append_page(&build_pins_page(ctx), Some(>k::Label::new(Some("Pins"))));
|
||||
notebook.append_page(&build_hotspots_page(ctx), Some(>k::Label::new(Some("Hotspots"))));
|
||||
win.set_child(Some(¬ebook));
|
||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
root.set_margin_top(10);
|
||||
root.set_margin_bottom(4);
|
||||
root.set_margin_start(10);
|
||||
root.set_margin_end(10);
|
||||
|
||||
let refresh_slot: RefreshSlot = Rc::new(std::cell::RefCell::new(None));
|
||||
|
||||
// väljarrad: "Alla skärmar" + en toggle per skärm + återställ-knapp
|
||||
let scope_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
scope_row.set_halign(gtk::Align::Center);
|
||||
let updating = Rc::new(Cell::new(false));
|
||||
let mut scope_values: Vec<Option<String>> = vec![None];
|
||||
for (name, _) in monitor_list() {
|
||||
scope_values.push(Some(name));
|
||||
}
|
||||
#[allow(clippy::type_complexity)]
|
||||
let toggles: Rc<std::cell::RefCell<Vec<(Option<String>, gtk::ToggleButton)>>> =
|
||||
Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
for value in &scope_values {
|
||||
let label = match value {
|
||||
None => "Alla skärmar".to_string(),
|
||||
Some(n) => n.clone(),
|
||||
};
|
||||
let btn = gtk::ToggleButton::with_label(&label);
|
||||
btn.set_active(value.is_none());
|
||||
{
|
||||
let value = value.clone();
|
||||
let updating = updating.clone();
|
||||
let refresh_slot = refresh_slot.clone();
|
||||
btn.connect_toggled(move |b| {
|
||||
if updating.get() {
|
||||
return;
|
||||
}
|
||||
if b.is_active() {
|
||||
SCOPE.with(|s| *s.borrow_mut() = value.clone());
|
||||
if let Some(f) = refresh_slot.borrow().as_ref() {
|
||||
f();
|
||||
}
|
||||
} else if current_scope() == value {
|
||||
// hindra av-toggling av den aktiva
|
||||
updating.set(true);
|
||||
b.set_active(true);
|
||||
updating.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
scope_row.append(&btn);
|
||||
toggles.borrow_mut().push((value.clone(), btn));
|
||||
}
|
||||
|
||||
let reset_btn = gtk::Button::with_label("Återställ skärmen till globalt");
|
||||
reset_btn.add_css_class("destructive-action");
|
||||
reset_btn.set_halign(gtk::Align::Center);
|
||||
reset_btn.set_visible(false);
|
||||
{
|
||||
let ctx2 = ctx.clone();
|
||||
let refresh_slot = refresh_slot.clone();
|
||||
reset_btn.connect_clicked(move |_| {
|
||||
if let Some(conn) = current_scope() {
|
||||
commit(&ctx2, |_| {}); // no-op: säkrar att ev. debounce inte skriver gammalt
|
||||
ctx2.cfg.borrow_mut().outputs.remove(&conn);
|
||||
if let Err(e) = config::save(&ctx2.cfg_path, &ctx2.cfg.borrow()) {
|
||||
warn!("kunde inte spara config: {e}");
|
||||
}
|
||||
if let Some(cb) = ctx2.on_config_changed.borrow().as_ref() {
|
||||
cb();
|
||||
}
|
||||
if let Some(f) = refresh_slot.borrow().as_ref() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let preview = build_preview(ctx, refresh_slot.clone());
|
||||
let hint = gtk::Label::new(Some(
|
||||
"Klicka på en skärm för att ställa in just den (utanför = alla).\n\
|
||||
* = skärmen har egna inställningar. Som standard gäller allt alla skärmar.",
|
||||
));
|
||||
hint.add_css_class("dim-label");
|
||||
hint.set_halign(gtk::Align::Center);
|
||||
hint.set_justify(gtk::Justification::Center);
|
||||
|
||||
let holder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
holder.set_vexpand(true);
|
||||
holder.append(&build_notebook(ctx));
|
||||
|
||||
// refresh: uppdatera togglar/preview/reset + bygg om sidorna
|
||||
{
|
||||
let ctx2 = ctx.clone();
|
||||
let holder2 = holder.clone();
|
||||
let toggles2 = toggles.clone();
|
||||
let updating2 = updating.clone();
|
||||
let reset2 = reset_btn.clone();
|
||||
let refresh: Rc<dyn Fn()> = Rc::new(move || {
|
||||
let scope = current_scope();
|
||||
updating2.set(true);
|
||||
for (value, btn) in toggles2.borrow().iter() {
|
||||
btn.set_active(*value == scope);
|
||||
}
|
||||
updating2.set(false);
|
||||
reset2.set_visible(scope.is_some());
|
||||
while let Some(c) = holder2.first_child() {
|
||||
holder2.remove(&c);
|
||||
}
|
||||
holder2.append(&build_notebook(&ctx2));
|
||||
PREVIEW.with(|p| {
|
||||
if let Some(da) = p.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||||
da.queue_draw();
|
||||
}
|
||||
});
|
||||
});
|
||||
*refresh_slot.borrow_mut() = Some(refresh);
|
||||
}
|
||||
|
||||
root.append(&scope_row);
|
||||
root.append(&preview);
|
||||
root.append(&hint);
|
||||
root.append(&reset_btn);
|
||||
root.append(&holder);
|
||||
win.set_child(Some(&root));
|
||||
|
||||
OPEN_WINDOW.with(|w| *w.borrow_mut() = Some(win.downgrade()));
|
||||
win.present();
|
||||
}
|
||||
|
||||
// hindra att Cell-importen städas bort av misstag när sidor ändras
|
||||
#[allow(unused)]
|
||||
fn _keep(_: Cell<u8>) {}
|
||||
|
||||
92
src/style.rs
92
src/style.rs
@@ -1,12 +1,22 @@
|
||||
// Config → CSS. All theming (färger, transparens, skuggor, accent,
|
||||
// storlekar, animationer) genereras här och laddas i en CssProvider som
|
||||
// byts ut vid live-omladdning.
|
||||
// byts ut vid live-omladdning. Skärmar med per-skärm-överridar får egna
|
||||
// CSS-block scopade på dockfönstrets output-klass (o-<connector>).
|
||||
|
||||
use gtk4 as gtk;
|
||||
use gtk4::gdk;
|
||||
|
||||
use crate::config::{Config, IndicatorStyle};
|
||||
|
||||
/// CSS-klass för en viss skärms dockfönster.
|
||||
pub fn output_class(connector: &str) -> String {
|
||||
let safe: String = connector
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
format!("o-{safe}")
|
||||
}
|
||||
|
||||
/// "#rrggbb" + alpha → "rgba(r, g, b, a)". Trasig färg → grå.
|
||||
fn rgba(hex: &str, alpha: f64) -> String {
|
||||
let h = hex.trim_start_matches('#');
|
||||
@@ -22,7 +32,9 @@ fn rgba(hex: &str, alpha: f64) -> String {
|
||||
format!("rgba({r}, {g}, {b}, {alpha:.3})")
|
||||
}
|
||||
|
||||
pub fn build_css(cfg: &Config) -> String {
|
||||
/// De utseende-styrda reglerna, med valfritt selektor-prefix
|
||||
/// ("" globalt, "window.o-DP-1 " för en enskild skärm).
|
||||
fn css_body(cfg: &Config, p: &str) -> String {
|
||||
let a = &cfg.appearance;
|
||||
let bg = rgba(&a.background, a.opacity);
|
||||
let border = rgba(&a.border.color, a.border.opacity);
|
||||
@@ -52,18 +64,7 @@ pub fn build_css(cfg: &Config) -> String {
|
||||
|
||||
format!(
|
||||
r#"
|
||||
window.waydock,
|
||||
window.waydock-sensor,
|
||||
window.waydock-hotspot {{
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
window.waydock-sensor box,
|
||||
window.waydock-hotspot box {{
|
||||
background-color: rgba(0, 0, 0, 0.004);
|
||||
}}
|
||||
|
||||
.dock-panel {{
|
||||
{p}.dock-panel {{
|
||||
background-color: {bg};
|
||||
border-radius: {radius}px;
|
||||
border: {bwidth}px solid {border};
|
||||
@@ -71,7 +72,7 @@ window.waydock-hotspot box {{
|
||||
{shadow}
|
||||
}}
|
||||
|
||||
.dock-item {{
|
||||
{p}.dock-item {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: {item_radius}px;
|
||||
@@ -82,33 +83,28 @@ window.waydock-hotspot box {{
|
||||
{transition}
|
||||
}}
|
||||
|
||||
.dock-item:hover {{
|
||||
{p}.dock-item:hover {{
|
||||
background-color: {accent_soft};
|
||||
}}
|
||||
|
||||
.dock-item:hover image {{
|
||||
{p}.dock-item:hover image {{
|
||||
{hover_zoom}
|
||||
}}
|
||||
|
||||
.dock-item image {{
|
||||
{p}.dock-item image {{
|
||||
{transition}
|
||||
}}
|
||||
|
||||
.dock-item.urgent {{
|
||||
{p}.dock-item.urgent {{
|
||||
background-color: {accent_soft};
|
||||
animation: waydock-pulse 900ms ease-in-out infinite alternate;
|
||||
}}
|
||||
|
||||
@keyframes waydock-pulse {{
|
||||
from {{ background-color: transparent; }}
|
||||
to {{ background-color: {accent_soft}; }}
|
||||
}}
|
||||
|
||||
.indicator-box {{
|
||||
{p}.indicator-box {{
|
||||
min-height: {ind_h}px;
|
||||
}}
|
||||
|
||||
.indicator {{
|
||||
{p}.indicator {{
|
||||
background-color: {accent};
|
||||
border-radius: {ind_radius}px;
|
||||
min-width: {ind_w}px;
|
||||
@@ -116,11 +112,11 @@ window.waydock-hotspot box {{
|
||||
margin: 0 1px;
|
||||
}}
|
||||
|
||||
.indicator.inactive {{
|
||||
{p}.indicator.inactive {{
|
||||
background-color: {accent_dim};
|
||||
}}
|
||||
|
||||
.group-badge {{
|
||||
{p}.group-badge {{
|
||||
background-color: {accent};
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
@@ -131,24 +127,25 @@ window.waydock-hotspot box {{
|
||||
padding: 0 2px;
|
||||
}}
|
||||
|
||||
popover.waydock-menu contents {{
|
||||
{p}popover.waydock-menu contents {{
|
||||
background-color: {menu_bg};
|
||||
border-radius: 10px;
|
||||
border: 1px solid {border};
|
||||
}}
|
||||
|
||||
popover.waydock-menu modelbutton,
|
||||
popover.waydock-menu button.flat {{
|
||||
{p}popover.waydock-menu modelbutton,
|
||||
{p}popover.waydock-menu button.flat {{
|
||||
color: #eeeeee;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}}
|
||||
|
||||
popover.waydock-menu modelbutton:hover,
|
||||
popover.waydock-menu button.flat:hover {{
|
||||
{p}popover.waydock-menu modelbutton:hover,
|
||||
{p}popover.waydock-menu button.flat:hover {{
|
||||
background-color: {accent_soft};
|
||||
}}
|
||||
"#,
|
||||
p = p,
|
||||
bg = bg,
|
||||
radius = a.corner_radius,
|
||||
bwidth = a.border.width,
|
||||
@@ -170,6 +167,35 @@ popover.waydock-menu button.flat:hover {{
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_css(cfg: &Config) -> String {
|
||||
let mut css = String::from(
|
||||
r#"
|
||||
window.waydock,
|
||||
window.waydock-sensor,
|
||||
window.waydock-hotspot {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
window.waydock-sensor box,
|
||||
window.waydock-hotspot box {
|
||||
background-color: rgba(0, 0, 0, 0.004);
|
||||
}
|
||||
|
||||
/* utan "to" animeras tillbaka till regelns egen bakgrund (accenten) */
|
||||
@keyframes waydock-pulse {
|
||||
from { background-color: transparent; }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
css += &css_body(cfg, "");
|
||||
// skärmar med överridar får egna, mer specifika block
|
||||
for name in cfg.outputs.keys() {
|
||||
let eff = cfg.for_output(name);
|
||||
css += &css_body(&eff, &format!("window.{} ", output_class(name)));
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
/// Ladda (eller byt ut) den globala CSS-providern.
|
||||
pub fn apply(cfg: &Config, previous: Option<>k::CssProvider>) -> gtk::CssProvider {
|
||||
let display = gdk::Display::default().expect("ingen display");
|
||||
|
||||
Reference in New Issue
Block a user