waydock v0.1: panel, pins, fönstergrupper, menyer, settings-GUI, live-config
Some checks failed
check / check (push) Failing after 52s

M0–M5 ur doc/plan.md: layer-shell-dock per skärm med transparent
#222222-ö, pins + körande appar (gruppering, badges, indikatorer),
klick/skroll/mittklick, konfigurerbar högerklicksmeny, tomyta-meny,
settings-GUI som redigerar JSON-configen live, filewatcher,
Wayfire-IPC-stubbar (scale, flytta-till-skärm), CI + release-flöde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 23:24:29 +02:00
commit bc2fa65558
21 changed files with 4579 additions and 0 deletions

434
src/config.rs Normal file
View File

@@ -0,0 +1,434 @@
// JSON-configen: schema, defaults, load/save och live-omladdning.
// Trasig config skrivs aldrig över — vi loggar och kör på defaults.
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
use gtk4::glib;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Position {
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Layer {
Top,
Overlay,
Bottom,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ClickAction {
FocusOrMinimize,
Focus,
Cycle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum MiddleClick {
LaunchNew,
Close,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ScrollAction {
CycleWindows,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum IndicatorStyle {
Dot,
Line,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MenuItem {
Minimize,
Maximize,
MoveToOutput,
Pin,
Close,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Border {
pub color: String,
pub opacity: f64,
pub width: u32,
}
impl Default for Border {
fn default() -> Self {
Self { color: "#ffffff".into(), opacity: 0.08, width: 1 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Shadow {
pub enabled: bool,
pub blur: u32,
pub opacity: f64,
}
impl Default for Shadow {
fn default() -> Self {
Self { enabled: true, blur: 18, opacity: 0.45 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Indicator {
pub style: IndicatorStyle,
pub max: u32,
}
impl Default for Indicator {
fn default() -> Self {
Self { style: IndicatorStyle::Dot, max: 3 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Appearance {
pub background: String,
pub opacity: f64,
pub corner_radius: u32,
pub border: Border,
pub shadow: Shadow,
pub accent: String,
pub icon_size: i32,
pub icon_spacing: i32,
pub padding: i32,
pub hover_zoom: bool,
pub animations: bool,
pub indicator: Indicator,
}
impl Default for Appearance {
fn default() -> Self {
Self {
background: "#222222".into(),
opacity: 0.72,
corner_radius: 14,
border: Border::default(),
shadow: Shadow::default(),
accent: "#c9545d".into(),
icon_size: 36,
icon_spacing: 6,
padding: 6,
hover_zoom: true,
animations: true,
indicator: Indicator::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Behavior {
pub click: ClickAction,
pub middle_click: MiddleClick,
pub scroll: ScrollAction,
pub group_apps: bool,
pub tooltips: bool,
pub urgent_pulse: bool,
}
impl Default for Behavior {
fn default() -> Self {
Self {
click: ClickAction::FocusOrMinimize,
middle_click: MiddleClick::LaunchNew,
scroll: ScrollAction::CycleWindows,
group_apps: true,
tooltips: true,
urgent_pulse: true,
}
}
}
#[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>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SpecialButtons {
pub show_desktop: bool,
pub overview: bool,
}
impl Default for SpecialButtons {
fn default() -> Self {
Self { show_desktop: true, overview: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AutohideMode {
Dodge,
Always,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Autohide {
pub enabled: bool,
pub mode: AutohideMode,
pub hide_delay_ms: u32,
pub reveal_px: i32,
pub animation_ms: u32,
}
impl Default for Autohide {
fn default() -> Self {
Self {
enabled: false,
mode: AutohideMode::Dodge,
hide_delay_ms: 500,
reveal_px: 2,
animation_ms: 200,
}
}
}
/// En hotspot-action: kör ett kommando eller anropa Wayfire IPC.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HotspotAction {
Exec(String),
Ipc(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Corners {
pub top_left: Option<HotspotAction>,
pub top_right: Option<HotspotAction>,
pub bottom_left: Option<HotspotAction>,
pub bottom_right: Option<HotspotAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Edges {
pub top: Option<HotspotAction>,
pub bottom: Option<HotspotAction>,
pub left: Option<HotspotAction>,
pub right: Option<HotspotAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Hotspots {
pub dwell_ms: u32,
pub corners: Corners,
pub edges: Edges,
}
impl Default for Hotspots {
fn default() -> Self {
Self { dwell_ms: 300, corners: Corners::default(), edges: Edges::default() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
pub level: String,
pub file: Option<String>,
}
impl Default for LogConfig {
fn default() -> Self {
Self { level: "info".into(), file: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub position: Position,
pub centered: bool,
pub margin: i32,
pub layer: Layer,
pub avoid_windows: bool,
pub appearance: Appearance,
pub behavior: Behavior,
pub context_menu: Vec<MenuItem>,
pub pins: Vec<String>,
pub special_buttons: SpecialButtons,
pub outputs: HashMap<String, OutputConfig>,
pub autohide: Autohide,
pub hotspots: Hotspots,
pub log: LogConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
position: Position::Bottom,
centered: true,
margin: 8,
layer: Layer::Top,
avoid_windows: false,
appearance: Appearance::default(),
behavior: Behavior::default(),
context_menu: vec![
MenuItem::Minimize,
MenuItem::Maximize,
MenuItem::MoveToOutput,
MenuItem::Pin,
MenuItem::Close,
],
pins: vec![
"firefox".into(),
"org.kde.konsole".into(),
"org.kde.dolphin".into(),
],
special_buttons: SpecialButtons::default(),
outputs: HashMap::new(),
autohide: Autohide::default(),
hotspots: Hotspots::default(),
log: LogConfig::default(),
}
}
}
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)
}
pub fn output_enabled(&self, output: &str) -> bool {
self.outputs
.get(output)
.and_then(|o| o.enabled)
.unwrap_or(true)
}
pub fn only_this_output(&self, output: &str) -> bool {
self.outputs
.get(output)
.and_then(|o| o.only_this_output)
.unwrap_or(false)
}
}
pub fn default_path() -> PathBuf {
glib::user_config_dir().join("waydock").join("config.json")
}
/// Läs configen; skapa default-fil om den saknas. Trasig fil → defaults.
pub fn load_or_create(path: &Path) -> Config {
match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<Config>(&text) {
Ok(cfg) => {
info!("läste config från {}", path.display());
cfg
}
Err(e) => {
error!("trasig config ({e}) — kör med defaults, filen lämnas orörd");
Config::default()
}
},
Err(_) => {
let cfg = Config::default();
if let Err(e) = save(path, &cfg) {
warn!("kunde inte skriva default-config: {e}");
} else {
info!("skapade default-config i {}", path.display());
}
cfg
}
}
}
pub fn save(path: &Path, cfg: &Config) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(cfg).expect("config är alltid serialiserbar");
// atomiskt: skriv temp + rename, så filewatchers aldrig ser en halv fil
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
/// Filewatcher med debounce: anropar `on_change` på GTK-tråden när
/// configfilen ändrats (oavsett om det var settings-GUI:t eller en editor).
pub fn watch(path: &Path, on_change: Rc<dyn Fn()>) {
use notify::{RecursiveMode, Watcher};
use std::cell::Cell;
let (tx, rx) = async_channel::unbounded::<()>();
let file_name = path.file_name().map(|s| s.to_os_string());
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
let hit = ev
.paths
.iter()
.any(|p| p.file_name().map(|s| s.to_os_string()) == file_name);
if hit {
let _ = tx.send_blocking(());
}
}
}) {
Ok(w) => w,
Err(e) => {
warn!("ingen filewatcher ({e}) — live-omladdning avstängd");
return;
}
};
// bevaka katalogen (editorer gör ofta rename-danser runt filen)
if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
warn!("kunde inte bevaka {}: {e}", dir.display());
return;
}
// watcher måste leva så länge processen — läck den medvetet
std::mem::forget(watcher);
// debounce på GTK-tråden: kör on_change först när det varit tyst 150 ms
let generation = Rc::new(Cell::new(0u64));
glib::MainContext::default().spawn_local(async move {
while rx.recv().await.is_ok() {
let this_gen = generation.get() + 1;
generation.set(this_gen);
let generation = generation.clone();
let on_change = on_change.clone();
glib::timeout_add_local_once(Duration::from_millis(150), move || {
if generation.get() == this_gen {
on_change();
}
});
}
});
}
/// Delad, muterbar config + sökväg — det alla moduler håller i.
pub type SharedConfig = Rc<RefCell<Config>>;

402
src/dock.rs Normal file
View File

@@ -0,0 +1,402 @@
// Dockfönstren: ett layer-shell-fönster per skärm. Bygger ikonraden av
// pins + körande fönstergrupper och kopplar klick/skroll/menyer.
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use gtk4 as gtk;
use gtk4::gdk;
use gtk4::glib;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use log::{debug, info};
use crate::config::{
ClickAction, Layer as CfgLayer, MiddleClick, Position, ScrollAction, SharedConfig,
};
use crate::icons::{self, AppMeta};
use crate::menu;
use crate::toplevel::{ToplevelInfo, ToplevelService};
use crate::wayfire_ipc;
/// Allt delat som dockarna behöver nå.
pub struct Ctx {
pub cfg: SharedConfig,
pub cfg_path: PathBuf,
pub service: RefCell<Option<Rc<ToplevelService>>>,
pub docks: RefCell<Vec<Dock>>,
/// målet för den öppna kontextmenyn (menu.rs läser)
pub menu_target: RefCell<Option<menu::MenuTarget>>,
pub app: gtk::Application,
/// körs när configen ändrats (sätts av main; applicerar CSS/rebuild)
pub on_config_changed: RefCell<Option<Rc<dyn Fn()>>>,
/// generationsräknare för settings-GUI:ts debouncade sparningar
pub save_generation: std::cell::Cell<u64>,
}
pub struct Dock {
pub window: gtk::ApplicationWindow,
pub panel: gtk::Box,
pub connector: String,
}
/// En ikon i docken: en pin och/eller en grupp körande fönster.
pub struct Item {
pub meta: AppMeta,
pub windows: Vec<ToplevelInfo>,
pub pinned: bool,
}
fn orientation(pos: &Position) -> gtk::Orientation {
match pos {
Position::Left | Position::Right => gtk::Orientation::Vertical,
_ => gtk::Orientation::Horizontal,
}
}
fn configure_layer(win: &gtk::ApplicationWindow, ctx: &Ctx, monitor: &gdk::Monitor) {
let cfg = ctx.cfg.borrow();
win.init_layer_shell();
win.set_namespace(Some("waydock"));
win.set_monitor(Some(monitor));
win.set_layer(match cfg.layer {
CfgLayer::Top => Layer::Top,
CfgLayer::Overlay => Layer::Overlay,
CfgLayer::Bottom => Layer::Bottom,
});
win.set_keyboard_mode(KeyboardMode::None);
let (main_edge, side_a, side_b) = match cfg.position {
Position::Bottom => (Edge::Bottom, Edge::Left, Edge::Right),
Position::Top => (Edge::Top, Edge::Left, Edge::Right),
Position::Left => (Edge::Left, Edge::Top, Edge::Bottom),
Position::Right => (Edge::Right, Edge::Top, Edge::Bottom),
};
win.set_anchor(main_edge, true);
win.set_margin(main_edge, cfg.margin);
if !cfg.centered {
// full bredd/höjd längs kanten
win.set_anchor(side_a, true);
win.set_anchor(side_b, true);
}
if cfg.avoid_windows {
win.auto_exclusive_zone_enable();
} else {
win.set_exclusive_zone(0);
}
}
/// Bygg om alla dockfönster från grunden (vid start, configbyte, skärmbyte).
pub fn rebuild_all(ctx: &Rc<Ctx>) {
for dock in ctx.docks.borrow_mut().drain(..) {
dock.window.destroy();
}
let display = gdk::Display::default().expect("ingen display");
let monitors = display.monitors();
let mut docks = 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}"));
if !ctx.cfg.borrow().output_enabled(&connector) {
debug!("skärm {connector} avstängd i configen");
continue;
}
docks.push(build_dock(ctx, &monitor, connector));
}
*ctx.docks.borrow_mut() = docks;
refresh_items(ctx);
}
fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Dock {
let cfg = ctx.cfg.borrow();
let win = gtk::ApplicationWindow::new(&ctx.app);
win.add_css_class("waydock");
let orient = orientation(&cfg.position);
let panel = gtk::Box::new(orient, 0);
panel.add_css_class("dock-panel");
panel.set_halign(gtk::Align::Center);
panel.set_valign(gtk::Align::Center);
// yttre marginal = plats för skuggan att rita
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);
outer.append(&panel);
win.set_child(Some(&outer));
drop(cfg);
configure_layer(&win, ctx, monitor);
menu::setup_dock_actions(ctx, &win);
// högerklick på tom panelyta → panelmenyn
let empty_click = gtk::GestureClick::new();
empty_click.set_button(3);
{
let ctx = ctx.clone();
let panel2 = panel.clone();
empty_click.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
menu::popup_panel_menu(&ctx, &panel2, x, y);
});
}
panel.add_controller(empty_click);
win.present();
info!("dock uppe på {connector}");
Dock { window: win, panel, connector }
}
/// Gruppera snapshot → ikonrad för en viss skärm.
fn build_items(ctx: &Ctx, connector: &str, snapshot: &[ToplevelInfo]) -> Vec<Item> {
let cfg = ctx.cfg.borrow();
let only_here = cfg.only_this_output(connector);
let group = cfg.behavior.group_apps;
let mut items: Vec<Item> = cfg
.pins_for(connector)
.iter()
.filter_map(|pin| icons::lookup_pin(pin))
.map(|meta| Item { meta, windows: Vec::new(), pinned: true })
.collect();
for t in snapshot {
if only_here && !t.outputs.iter().any(|o| o == connector) {
continue;
}
let meta = icons::lookup(&t.app_id);
let existing = items
.iter_mut()
.find(|i| i.meta.key == meta.key && (group || i.windows.is_empty()));
match existing {
Some(item) => item.windows.push(t.clone()),
None => items.push(Item { meta, windows: vec![t.clone()], pinned: false }),
}
}
items
}
/// Uppdatera ikonraderna i alla dockor (körs vid varje fönsterhändelse).
pub fn refresh_items(ctx: &Rc<Ctx>) {
let snapshot = ctx
.service
.borrow()
.as_ref()
.map(|s| s.snapshot())
.unwrap_or_default();
for dock in ctx.docks.borrow().iter() {
while let Some(child) = dock.panel.first_child() {
dock.panel.remove(&child);
}
add_special_buttons(ctx, dock);
for item in build_items(ctx, &dock.connector, &snapshot) {
let widget = build_item_widget(ctx, Rc::new(item));
dock.panel.append(&widget);
}
}
}
fn special_button(icon_name: &str, tooltip: &str) -> gtk::Button {
let btn = gtk::Button::new();
btn.add_css_class("dock-item");
btn.set_has_frame(false);
let img = gtk::Image::from_icon_name(icon_name);
img.set_pixel_size(24);
btn.set_child(Some(&img));
btn.set_tooltip_text(Some(tooltip));
btn
}
fn add_special_buttons(ctx: &Rc<Ctx>, dock: &Dock) {
let cfg = ctx.cfg.borrow();
let specials = cfg.special_buttons.clone();
let icon_size = cfg.appearance.icon_size;
drop(cfg);
let _ = icon_size;
if specials.overview && wayfire_ipc::available() {
let btn = special_button("view-grid-symbolic", "Översikt (scale)");
btn.connect_clicked(|_| wayfire_ipc::toggle_scale());
dock.panel.append(&btn);
}
if specials.show_desktop {
let btn = special_button("user-desktop-symbolic", "Visa skrivbordet");
let ctx = ctx.clone();
btn.connect_clicked(move |_| {
if let Some(service) = ctx.service.borrow().as_ref() {
service.toggle_show_desktop();
}
});
dock.panel.append(&btn);
}
}
fn build_item_widget(ctx: &Rc<Ctx>, item: Rc<Item>) -> gtk::Button {
let cfg = ctx.cfg.borrow();
let icon_size = cfg.appearance.icon_size;
let max_dots = cfg.appearance.indicator.max as usize;
let tooltips = cfg.behavior.tooltips;
drop(cfg);
let btn = gtk::Button::new();
btn.add_css_class("dock-item");
btn.set_has_frame(false);
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 2);
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&icons::image_for(&item.meta, icon_size)));
// siffer-badge för grupper med flera fönster
if item.windows.len() > 1 {
let badge = gtk::Label::new(Some(&item.windows.len().to_string()));
badge.add_css_class("group-badge");
badge.set_halign(gtk::Align::End);
badge.set_valign(gtk::Align::Start);
overlay.add_overlay(&badge);
}
vbox.append(&overlay);
// indikatorprickar
let ind_box = gtk::Box::new(gtk::Orientation::Horizontal, 2);
ind_box.add_css_class("indicator-box");
ind_box.set_halign(gtk::Align::Center);
let any_active = item.windows.iter().any(|w| w.activated);
for _ in 0..item.windows.len().min(max_dots) {
let dot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
dot.add_css_class("indicator");
if !any_active {
dot.add_css_class("inactive");
}
ind_box.append(&dot);
}
vbox.append(&ind_box);
btn.set_child(Some(&vbox));
if tooltips {
let tip = match item.windows.len() {
0 => item.meta.display_name.clone(),
1 => {
let t = &item.windows[0].title;
if t.is_empty() { item.meta.display_name.clone() } else { t.clone() }
}
n => format!("{}{n} fönster", item.meta.display_name),
};
btn.set_tooltip_text(Some(&tip));
}
// klick: vänster/mitt/höger
let click = gtk::GestureClick::new();
click.set_button(0);
{
let ctx = ctx.clone();
let item = item.clone();
let btn2 = btn.clone();
click.connect_released(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
match gesture.current_button() {
1 => primary_action(&ctx, &item, &btn2),
2 => middle_action(&ctx, &item),
3 => menu::popup_item_menu(&ctx, &item, &btn2),
_ => {}
}
});
}
btn.add_controller(click);
// skroll: växla mellan gruppens fönster
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL);
{
let ctx = ctx.clone();
let item = item.clone();
scroll.connect_scroll(move |_, _, dy| {
if ctx.cfg.borrow().behavior.scroll == ScrollAction::CycleWindows {
cycle_windows(&ctx, &item, dy > 0.0);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
}
btn.add_controller(scroll);
btn
}
fn primary_action(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) {
let service = ctx.service.borrow();
let Some(service) = service.as_ref() else {
icons::launch(&item.meta);
return;
};
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 {
service.set_minimized(w, true);
} else {
service.activate(w);
}
}
_ => {
let click = ctx.cfg.borrow().behavior.click.clone();
if click == ClickAction::Cycle {
cycle_windows(ctx, item, true);
} else {
menu::popup_group_list(ctx, item, anchor);
}
}
}
}
fn middle_action(ctx: &Rc<Ctx>, item: &Rc<Item>) {
let action = ctx.cfg.borrow().behavior.middle_click.clone();
match action {
MiddleClick::LaunchNew => icons::launch(&item.meta),
MiddleClick::Close => {
if let Some(service) = ctx.service.borrow().as_ref() {
for w in &item.windows {
service.close(w);
}
}
}
MiddleClick::None => {}
}
}
pub fn cycle_windows(ctx: &Rc<Ctx>, item: &Item, forward: bool) {
if item.windows.is_empty() {
return;
}
let service = ctx.service.borrow();
let Some(service) = service.as_ref() else { return };
let idx = item.windows.iter().position(|w| w.activated);
let next = match idx {
Some(i) => {
if forward {
(i + 1) % item.windows.len()
} else {
(i + item.windows.len() - 1) % item.windows.len()
}
}
None => 0,
};
service.activate(&item.windows[next]);
}

107
src/icons.rs Normal file
View File

@@ -0,0 +1,107 @@
// app_id → .desktop-metadata (namn, ikon, launch). Allt cachas —
// uppslag sker bara första gången ett nytt app_id dyker upp.
use std::cell::RefCell;
use std::collections::HashMap;
use gtk4::{gdk, gio, prelude::*};
#[derive(Clone)]
pub struct AppMeta {
/// Nyckel som grupperar fönster och matchar pins: desktop-id utan
/// ".desktop" om det finns, annars app_id gemener.
pub key: String,
pub display_name: String,
pub icon: Option<gio::Icon>,
pub app_info: Option<gio::DesktopAppInfo>,
}
thread_local! {
static CACHE: RefCell<HashMap<String, AppMeta>> = RefCell::new(HashMap::new());
}
fn find_desktop(app_id: &str) -> Option<gio::DesktopAppInfo> {
let candidates = [
format!("{app_id}.desktop"),
format!("{}.desktop", app_id.to_lowercase()),
format!("{}.desktop", app_id.replace(' ', "-").to_lowercase()),
];
for c in &candidates {
if let Some(info) = gio::DesktopAppInfo::new(c) {
return Some(info);
}
}
// sista utväg: gios sökindex (matchar StartupWMClass, namn m.m.)
let hits = gio::DesktopAppInfo::search(app_id);
hits.first()
.and_then(|group| group.first())
.and_then(|id| gio::DesktopAppInfo::new(id.as_str()))
}
fn meta_from_info(info: gio::DesktopAppInfo, fallback_name: &str) -> AppMeta {
let key = info
.id()
.map(|id| id.trim_end_matches(".desktop").to_string())
.unwrap_or_else(|| fallback_name.to_lowercase());
AppMeta {
key,
display_name: info.display_name().to_string(),
icon: info.icon(),
app_info: Some(info),
}
}
/// Slå upp metadata för ett körande fönsters app_id.
pub fn lookup(app_id: &str) -> AppMeta {
CACHE.with(|c| {
if let Some(meta) = c.borrow().get(app_id) {
return meta.clone();
}
let meta = match find_desktop(app_id) {
Some(info) => meta_from_info(info, app_id),
None => {
// ingen .desktop — prova app_id som temaikon rakt av
let icon: Option<gio::Icon> = gdk::Display::default()
.map(|d| gtk4::IconTheme::for_display(&d))
.filter(|theme| theme.has_icon(app_id))
.map(|_| gio::ThemedIcon::new(app_id).upcast());
AppMeta {
key: app_id.to_lowercase(),
display_name: app_id.to_string(),
icon,
app_info: None,
}
}
};
c.borrow_mut().insert(app_id.to_string(), meta.clone());
meta
})
}
/// Slå upp en pin (desktop-id från configen).
pub fn lookup_pin(desktop_id: &str) -> Option<AppMeta> {
gio::DesktopAppInfo::new(&format!("{desktop_id}.desktop"))
.or_else(|| gio::DesktopAppInfo::new(desktop_id))
.map(|info| meta_from_info(info, desktop_id))
}
/// Starta en app (ny instans).
pub fn launch(meta: &AppMeta) {
if let Some(info) = &meta.app_info {
if let Err(e) = info.launch(&[], None::<&gio::AppLaunchContext>) {
log::warn!("kunde inte starta {}: {e}", meta.display_name);
}
} else {
log::warn!("ingen .desktop för {} — kan inte starta", meta.display_name);
}
}
/// Ikon-widget för en app i given storlek.
pub fn image_for(meta: &AppMeta, size: i32) -> gtk4::Image {
let img = match &meta.icon {
Some(icon) => gtk4::Image::from_gicon(icon),
None => gtk4::Image::from_icon_name("application-x-executable"),
};
img.set_pixel_size(size);
img
}

232
src/main.rs Normal file
View File

@@ -0,0 +1,232 @@
// waydock — modern, resurssnål dock för Wayfire.
// Se doc/architecture.md för helheten.
mod config;
mod dock;
mod icons;
mod menu;
mod settings;
mod style;
mod toplevel;
mod wayfire_ipc;
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration;
use gtk4 as gtk;
use gtk4::gdk;
use gtk4::glib;
use gtk4::prelude::*;
use log::{error, info, warn, LevelFilter};
const VERSION: &str = env!("CARGO_PKG_VERSION");
struct Cli {
config: Option<PathBuf>,
log: Option<String>,
log_file: Option<String>,
}
fn parse_cli() -> Cli {
let mut cli = Cli { config: None, log: None, log_file: None };
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--config" => cli.config = args.next().map(PathBuf::from),
"--log" => cli.log = args.next(),
"--log-file" => cli.log_file = args.next(),
"--version" | "-V" => {
println!("waydock {VERSION}");
std::process::exit(0);
}
"--help" | "-h" => {
println!(
"waydock {VERSION} — dock för Wayfire\n\n\
FLAGGOR:\n \
--config <fil> annan configfil (default ~/.config/waydock/config.json)\n \
--log <nivå> error|warn|info|debug|trace (även env WAYDOCK_LOG)\n \
--log-file <fil> skriv logg till fil\n \
--version visa version"
);
std::process::exit(0);
}
other => {
eprintln!("okänd flagga: {other} (se --help)");
std::process::exit(2);
}
}
}
cli
}
fn init_logging(cli: &Cli, cfg: &config::Config) {
let level_str = cli
.log
.clone()
.or_else(|| std::env::var("WAYDOCK_LOG").ok())
.unwrap_or_else(|| cfg.log.level.clone());
let level = match level_str.to_lowercase().as_str() {
"error" => LevelFilter::Error,
"warn" => LevelFilter::Warn,
"debug" => LevelFilter::Debug,
"trace" => LevelFilter::Trace,
_ => LevelFilter::Info,
};
let log_cfg = simplelog::ConfigBuilder::new()
.set_time_format_rfc3339()
.build();
let mut loggers: Vec<Box<dyn simplelog::SharedLogger>> = vec![simplelog::TermLogger::new(
level,
log_cfg.clone(),
simplelog::TerminalMode::Stderr,
simplelog::ColorChoice::Auto,
)];
let file_path = cli.log_file.clone().or_else(|| cfg.log.file.clone());
if let Some(path) = file_path {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
glib::home_dir().join(rest)
} else {
PathBuf::from(&path)
};
match std::fs::File::create(&expanded) {
Ok(f) => loggers.push(simplelog::WriteLogger::new(level, log_cfg, f)),
Err(e) => eprintln!("kunde inte öppna loggfil {path}: {e}"),
}
}
let _ = simplelog::CombinedLogger::init(loggers);
}
/// Delar av configen som kräver att fönstren byggs om (inte bara CSS).
fn layout_signature(cfg: &config::Config) -> String {
format!(
"{:?}|{}|{}|{:?}|{}|{}|{}|{:?}",
cfg.position,
cfg.centered,
cfg.margin,
cfg.layer,
cfg.avoid_windows,
cfg.appearance.shadow.enabled,
cfg.appearance.shadow.blur,
cfg.outputs.keys().collect::<Vec<_>>(),
)
}
fn main() -> glib::ExitCode {
let cli = parse_cli();
let cfg_path = cli.config.clone().unwrap_or_else(config::default_path);
let initial_cfg = config::load_or_create(&cfg_path);
init_logging(&cli, &initial_cfg);
info!("waydock {VERSION} — config: {}", cfg_path.display());
let app = gtk::Application::new(Some("eu.brassepc.waydock"), Default::default());
let started = Rc::new(Cell::new(false));
let cfg_path2 = cfg_path.clone();
app.connect_activate(move |app| {
if started.get() {
info!("waydock kör redan — ignorerar andra instansen");
return;
}
started.set(true);
activate(app, cfg_path2.clone());
});
// ge inte GTK våra egna flaggor
app.run_with_args::<&str>(&[])
}
fn activate(app: &gtk::Application, cfg_path: PathBuf) {
let cfg: config::SharedConfig = Rc::new(RefCell::new(config::load_or_create(&cfg_path)));
let ctx = Rc::new(dock::Ctx {
cfg: cfg.clone(),
cfg_path: cfg_path.clone(),
service: RefCell::new(None),
docks: RefCell::new(Vec::new()),
menu_target: RefCell::new(None),
app: app.clone(),
on_config_changed: RefCell::new(None),
save_generation: Cell::new(0),
});
// CSS + rebuild/refresh-logik
let css_provider: Rc<RefCell<Option<gtk::CssProvider>>> = Rc::new(RefCell::new(None));
let last_signature = Rc::new(RefCell::new(String::new()));
{
let ctx2 = ctx.clone();
let css_provider = css_provider.clone();
let last_signature = last_signature.clone();
let apply: Rc<dyn Fn()> = Rc::new(move || {
let cfg = ctx2.cfg.borrow();
let provider = style::apply(&cfg, css_provider.borrow().as_ref());
*css_provider.borrow_mut() = Some(provider);
let sig = layout_signature(&cfg);
drop(cfg);
if *last_signature.borrow() != sig {
*last_signature.borrow_mut() = sig;
dock::rebuild_all(&ctx2);
} else {
dock::refresh_items(&ctx2);
}
});
*ctx.on_config_changed.borrow_mut() = Some(apply);
}
// fönsterspårningen
{
let ctx2 = ctx.clone();
let on_update: Rc<dyn Fn()> = Rc::new(move || dock::refresh_items(&ctx2));
match toplevel::start(on_update) {
Ok(service) => *ctx.service.borrow_mut() = Some(service),
Err(e) => {
error!("{e} — docken visar bara pins");
}
}
}
if !wayfire_ipc::available() {
warn!("WAYFIRE_SOCKET saknas — översikt/flytta-till-skärm/dodge avstängda (aktivera ipc-pluginen i wayfire.ini)");
}
// första uppritningen
if let Some(cb) = ctx.on_config_changed.borrow().as_ref() {
cb();
}
// live-omladdning när configfilen ändras
{
let ctx2 = ctx.clone();
config::watch(
&cfg_path,
Rc::new(move || {
info!("configfilen ändrad — laddar om");
let fresh = config::load_or_create(&ctx2.cfg_path);
*ctx2.cfg.borrow_mut() = fresh;
if let Some(cb) = ctx2.on_config_changed.borrow().as_ref() {
cb();
}
}),
);
}
// skärmar in/ut → bygg om (debouncat, skärmar kommer i klungor)
{
let ctx2 = ctx.clone();
let pending = Rc::new(Cell::new(0u64));
let display = gdk::Display::default().expect("ingen display");
display.monitors().connect_items_changed(move |_, _, _, _| {
let generation = pending.get() + 1;
pending.set(generation);
let ctx3 = ctx2.clone();
let pending2 = pending.clone();
glib::timeout_add_local_once(Duration::from_millis(500), move || {
if pending2.get() == generation {
info!("skärmuppsättningen ändrad — bygger om dockorna");
dock::rebuild_all(&ctx3);
}
});
});
}
}

316
src/menu.rs Normal file
View File

@@ -0,0 +1,316 @@
// Kontextmenyer: höger­klick på app-ikon (konfigurerbar via
// config.context_menu), grupp-popup med fönsterlistan, samt panelmenyn
// på tom yta (Inställningar…, Ladda om, Avsluta).
use std::rc::Rc;
use gtk4 as gtk;
use gtk4::gdk;
use gtk4::gio;
use gtk4::glib;
use gtk4::prelude::*;
use log::warn;
use crate::config::{self, MenuItem};
use crate::dock::{Ctx, Item};
use crate::icons;
use crate::settings;
use crate::toplevel::ToplevelInfo;
use crate::wayfire_ipc;
/// Målet för den senast öppnade ikon-menyn (actions läser härifrån).
pub struct MenuTarget {
pub key: String,
pub windows: Vec<ToplevelInfo>,
pub pinned: bool,
pub connector: String,
}
fn with_service(ctx: &Ctx, f: impl Fn(&crate::toplevel::ToplevelService, &ToplevelInfo)) {
let target = ctx.menu_target.borrow();
let service = ctx.service.borrow();
if let (Some(t), Some(s)) = (target.as_ref(), service.as_ref()) {
for w in &t.windows {
f(s, w);
}
}
}
/// Registrera action-gruppen "dock" på ett dockfönster.
pub fn setup_dock_actions(ctx: &Rc<Ctx>, win: &gtk::ApplicationWindow) {
let group = gio::SimpleActionGroup::new();
let minimize = gio::SimpleAction::new("minimize", None);
{
let ctx = ctx.clone();
minimize.connect_activate(move |_, _| {
let all_min = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| t.windows.iter().all(|w| w.minimized))
.unwrap_or(false);
with_service(&ctx, |s, w| s.set_minimized(w, !all_min));
});
}
group.add_action(&minimize);
let maximize = gio::SimpleAction::new("maximize", None);
{
let ctx = ctx.clone();
maximize.connect_activate(move |_, _| {
let all_max = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| t.windows.iter().all(|w| w.maximized))
.unwrap_or(false);
with_service(&ctx, |s, w| s.set_maximized(w, !all_max));
});
}
group.add_action(&maximize);
let close = gio::SimpleAction::new("close", None);
{
let ctx = ctx.clone();
close.connect_activate(move |_, _| with_service(&ctx, |s, w| s.close(w)));
}
group.add_action(&close);
let pin = gio::SimpleAction::new("pin", None);
{
let ctx = ctx.clone();
pin.connect_activate(move |_, _| {
let connector = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| t.connector.clone())
.unwrap_or_default();
toggle_pin(&ctx, &connector);
});
}
group.add_action(&pin);
let move_to = gio::SimpleAction::new("move-to-output", Some(glib::VariantTy::STRING));
{
let ctx = ctx.clone();
move_to.connect_activate(move |_, param| {
let Some(output) = param.and_then(|v| v.get::<String>()) else { return };
let target = ctx.menu_target.borrow();
let Some(t) = target.as_ref() else { return };
for w in &t.windows {
match wayfire_ipc::find_view_id(&w.app_id, &w.title) {
Some(id) => wayfire_ipc::move_view_to_output(id, &output),
None => warn!("hittade ingen wayfire-vy för {}", w.app_id),
}
}
});
}
group.add_action(&move_to);
win.insert_action_group("dock", Some(&group));
}
fn toggle_pin(ctx: &Rc<Ctx>, connector: &str) {
let Some((key, pinned)) = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| (t.key.clone(), t.pinned))
else {
return;
};
{
let mut cfg = ctx.cfg.borrow_mut();
// skriv i output-egna pins om skärmen har egna, annars i de globala
let has_own = cfg
.outputs
.get(connector)
.map(|o| o.pins.is_some())
.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
};
if pinned {
pins.retain(|p| p.to_lowercase() != key);
} else if !pins.iter().any(|p| p.to_lowercase() == key) {
pins.push(key.clone());
}
if let Err(e) = config::save(&ctx.cfg_path, &cfg) {
warn!("kunde inte spara config: {e}");
}
}
crate::dock::refresh_items(ctx);
}
/// Popover som städar bort sig själv när den stängs.
fn autodispose(popover: &impl IsA<gtk::Popover>) {
let p = popover.as_ref().clone();
p.connect_closed(|p| {
let p = p.clone();
glib::idle_add_local_once(move || p.unparent());
});
}
/// Högerklicksmenyn på en app-ikon.
pub fn popup_item_menu(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) {
let cfg = ctx.cfg.borrow();
let has_windows = !item.windows.is_empty();
let all_min = has_windows && item.windows.iter().all(|w| w.minimized);
let all_max = has_windows && item.windows.iter().all(|w| w.maximized);
let menu = gio::Menu::new();
for entry in &cfg.context_menu {
match entry {
MenuItem::Minimize if has_windows => {
menu.append(
Some(if all_min { "Återställ" } else { "Minimera" }),
Some("dock.minimize"),
);
}
MenuItem::Maximize if has_windows => {
menu.append(
Some(if all_max { "Avmaximera" } else { "Maximera" }),
Some("dock.maximize"),
);
}
MenuItem::MoveToOutput if has_windows && wayfire_ipc::available() => {
let sub = gio::Menu::new();
let docks = ctx.docks.borrow();
for dock in docks.iter() {
let mi = gio::MenuItem::new(Some(&dock.connector), None);
mi.set_action_and_target_value(
Some("dock.move-to-output"),
Some(&dock.connector.to_variant()),
);
sub.append_item(&mi);
}
menu.append_submenu(Some("Flytta till skärm"), &sub);
}
MenuItem::Pin => {
menu.append(
Some(if item.pinned { "Släpp pin" } else { "Pinna" }),
Some("dock.pin"),
);
}
MenuItem::Close if has_windows => {
menu.append(Some("Stäng"), Some("dock.close"));
}
_ => {}
}
}
drop(cfg);
// hitta skärmens connector via dockan som äger knappen
let connector = ctx
.docks
.borrow()
.iter()
.find(|d| anchor.ancestor(gtk::ApplicationWindow::static_type())
.map(|w| w == *d.window.upcast_ref::<gtk::Widget>())
.unwrap_or(false))
.map(|d| d.connector.clone())
.unwrap_or_default();
*ctx.menu_target.borrow_mut() = Some(MenuTarget {
key: item.meta.key.clone(),
windows: item.windows.clone(),
pinned: item.pinned,
connector,
});
let popover = gtk::PopoverMenu::from_model(Some(&menu));
popover.add_css_class("waydock-menu");
popover.set_parent(anchor);
autodispose(&popover);
popover.popup();
}
/// Vänsterklick på grupp med flera fönster: lista fönstren.
pub fn popup_group_list(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) {
let popover = gtk::Popover::new();
popover.add_css_class("waydock-menu");
let list = gtk::Box::new(gtk::Orientation::Vertical, 2);
for w in &item.windows {
let row = gtk::Button::new();
row.add_css_class("flat");
let title = if w.title.is_empty() { item.meta.display_name.clone() } else { w.title.clone() };
let label = gtk::Label::new(Some(&title));
label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
label.set_max_width_chars(40);
label.set_halign(gtk::Align::Start);
row.set_child(Some(&label));
let ctx2 = ctx.clone();
let w2 = w.clone();
let pop2 = popover.clone();
row.connect_clicked(move |_| {
if let Some(s) = ctx2.service.borrow().as_ref() {
s.activate(&w2);
}
pop2.popdown();
});
list.append(&row);
}
popover.set_child(Some(&list));
popover.set_parent(anchor);
autodispose(&popover);
popover.popup();
}
/// Högerklick på tom panelyta.
pub fn popup_panel_menu(ctx: &Rc<Ctx>, panel: &gtk::Box, x: f64, y: f64) {
let menu = gio::Menu::new();
menu.append(Some("Inställningar…"), Some("panel.settings"));
menu.append(Some("Ladda om config"), Some("panel.reload"));
menu.append(Some("Avsluta waydock"), Some("panel.quit"));
let group = gio::SimpleActionGroup::new();
let open_settings = gio::SimpleAction::new("settings", None);
{
let ctx = ctx.clone();
open_settings.connect_activate(move |_, _| settings::open(&ctx));
}
group.add_action(&open_settings);
let reload = gio::SimpleAction::new("reload", None);
{
let ctx = ctx.clone();
reload.connect_activate(move |_, _| {
let fresh = config::load_or_create(&ctx.cfg_path);
*ctx.cfg.borrow_mut() = fresh;
if let Some(cb) = ctx.on_config_changed.borrow().as_ref() {
cb();
}
});
}
group.add_action(&reload);
let quit = gio::SimpleAction::new("quit", None);
{
let ctx = ctx.clone();
quit.connect_activate(move |_, _| ctx.app.quit());
}
group.add_action(&quit);
panel.insert_action_group("panel", Some(&group));
let popover = gtk::PopoverMenu::from_model(Some(&menu));
popover.add_css_class("waydock-menu");
popover.set_parent(panel);
popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
autodispose(&popover);
popover.popup();
}
/// Starta en app från menyn (används inte ännu — behålls för settings-GUI:t).
#[allow(dead_code)]
pub fn launch_item(item: &Item) {
icons::launch(&item.meta);
}

529
src/settings.rs Normal file
View File

@@ -0,0 +1,529 @@
// Settings-GUI:t: ett GTK4-fönster som redigerar hela JSON-configen.
// Varje widgetändring uppdaterar delade configen, applicerar direkt
// (snabb feedback) och sparar filen debouncat — samma fil som kan
// handredigeras, filewatchern håller allt i synk.
use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use gtk4 as gtk;
use gtk4::gdk;
use gtk4::glib;
use gtk4::prelude::*;
use log::warn;
use crate::config::{
self, AutohideMode, ClickAction, HotspotAction, IndicatorStyle, Layer, MenuItem,
MiddleClick, Position, ScrollAction,
};
use crate::dock::Ctx;
thread_local! {
static OPEN_WINDOW: std::cell::RefCell<Option<glib::WeakRef<gtk::Window>>> =
const { std::cell::RefCell::new(None) };
}
/// Uppdatera config + applicera + spara (debouncat 300 ms).
fn commit(ctx: &Rc<Ctx>, mutate: impl FnOnce(&mut config::Config)) {
mutate(&mut ctx.cfg.borrow_mut());
if let Some(cb) = ctx.on_config_changed.borrow().as_ref() {
cb();
}
let generation = ctx.save_generation.get() + 1;
ctx.save_generation.set(generation);
let ctx = ctx.clone();
glib::timeout_add_local_once(Duration::from_millis(300), move || {
if ctx.save_generation.get() == generation {
if let Err(e) = config::save(&ctx.cfg_path, &ctx.cfg.borrow()) {
warn!("kunde inte spara config: {e}");
}
}
});
}
fn hex_of(rgba: &gdk::RGBA) -> String {
format!(
"#{:02x}{:02x}{:02x}",
(rgba.red() * 255.0) as u8,
(rgba.green() * 255.0) as u8,
(rgba.blue() * 255.0) as u8
)
}
// -- widgethjälpare ------------------------------------------------------
fn page() -> (gtk::ScrolledWindow, gtk::Box) {
let list = gtk::Box::new(gtk::Orientation::Vertical, 6);
list.set_margin_top(12);
list.set_margin_bottom(12);
list.set_margin_start(14);
list.set_margin_end(14);
let scroll = gtk::ScrolledWindow::new();
scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
scroll.set_child(Some(&list));
(scroll, list)
}
fn row(list: &gtk::Box, label: &str, widget: &impl IsA<gtk::Widget>) {
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 10);
let lbl = gtk::Label::new(Some(label));
lbl.set_halign(gtk::Align::Start);
lbl.set_hexpand(true);
hbox.append(&lbl);
hbox.append(widget);
list.append(&hbox);
}
fn heading(list: &gtk::Box, text: &str) {
let lbl = gtk::Label::new(None);
lbl.set_markup(&format!("<b>{text}</b>"));
lbl.set_halign(gtk::Align::Start);
lbl.set_margin_top(10);
list.append(&lbl);
}
fn switch(ctx: &Rc<Ctx>, active: bool, set: impl Fn(&mut config::Config, bool) + 'static) -> gtk::Switch {
let sw = gtk::Switch::new();
sw.set_active(active);
sw.set_valign(gtk::Align::Center);
let ctx = ctx.clone();
let set = Rc::new(set);
sw.connect_active_notify(move |sw| {
let v = sw.is_active();
let set = set.clone();
commit(&ctx, move |c| set(c, v));
});
sw
}
fn spin(
ctx: &Rc<Ctx>,
value: f64,
range: (f64, f64),
step: f64,
set: impl Fn(&mut config::Config, f64) + 'static,
) -> gtk::SpinButton {
let sb = gtk::SpinButton::with_range(range.0, range.1, step);
sb.set_value(value);
let ctx = ctx.clone();
let set = Rc::new(set);
sb.connect_value_changed(move |sb| {
let v = sb.value();
let set = set.clone();
commit(&ctx, move |c| set(c, v));
});
sb
}
fn scale01(ctx: &Rc<Ctx>, value: f64, set: impl Fn(&mut config::Config, f64) + 'static) -> gtk::Scale {
let sc = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.01);
sc.set_value(value);
sc.set_size_request(160, -1);
sc.set_draw_value(false);
let ctx = ctx.clone();
let set = Rc::new(set);
sc.connect_value_changed(move |sc| {
let v = sc.value();
let set = set.clone();
commit(&ctx, move |c| set(c, v));
});
sc
}
fn color_button(
ctx: &Rc<Ctx>,
hex: &str,
set: impl Fn(&mut config::Config, String) + 'static,
) -> gtk::ColorDialogButton {
let btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new()));
if let Ok(rgba) = gdk::RGBA::parse(hex) {
btn.set_rgba(&rgba);
}
let ctx = ctx.clone();
let set = Rc::new(set);
btn.connect_rgba_notify(move |btn| {
let hex = hex_of(&btn.rgba());
let set = set.clone();
commit(&ctx, move |c| set(c, hex));
});
btn
}
fn dropdown(
ctx: &Rc<Ctx>,
options: &[&str],
selected: usize,
set: impl Fn(&mut config::Config, usize) + 'static,
) -> gtk::DropDown {
let dd = gtk::DropDown::from_strings(options);
dd.set_selected(selected as u32);
let ctx = ctx.clone();
let set = Rc::new(set);
dd.connect_selected_notify(move |dd| {
let i = dd.selected() as usize;
let set = set.clone();
commit(&ctx, move |c| set(c, i));
});
dd
}
// -- hotspot-hjälpare ----------------------------------------------------
fn hotspot_to_text(a: &Option<HotspotAction>) -> String {
match a {
Some(HotspotAction::Exec(cmd)) => format!("exec:{cmd}"),
Some(HotspotAction::Ipc(m)) => format!("ipc:{m}"),
None => String::new(),
}
}
fn text_to_hotspot(text: &str) -> Option<HotspotAction> {
let t = text.trim();
if let Some(cmd) = t.strip_prefix("exec:") {
Some(HotspotAction::Exec(cmd.trim().to_string()))
} else if let Some(m) = t.strip_prefix("ipc:") {
Some(HotspotAction::Ipc(m.trim().to_string()))
} else if t.is_empty() {
None
} else {
Some(HotspotAction::Exec(t.to_string()))
}
}
fn hotspot_entry(
ctx: &Rc<Ctx>,
current: &Option<HotspotAction>,
set: impl Fn(&mut config::Config, Option<HotspotAction>) + 'static,
) -> gtk::Entry {
let entry = gtk::Entry::new();
entry.set_text(&hotspot_to_text(current));
entry.set_placeholder_text(Some("exec:kommando eller ipc:scale/toggle"));
entry.set_width_chars(24);
let ctx = ctx.clone();
let set = Rc::new(set);
entry.connect_changed(move |e| {
let action = text_to_hotspot(&e.text());
let set = set.clone();
commit(&ctx, move |c| set(c, action));
});
entry
}
// -- sidorna -------------------------------------------------------------
fn build_appearance_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page();
let a = ctx.cfg.borrow().appearance.clone();
heading(&list, "Panel");
row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v));
row(&list, "Transparens", &scale01(ctx, a.opacity, |c, v| c.appearance.opacity = v));
row(&list, "Hörnradie", &spin(ctx, a.corner_radius as f64, (0.0, 40.0), 1.0, |c, v| c.appearance.corner_radius = v as u32));
row(&list, "Accentfärg", &color_button(ctx, &a.accent, |c, v| c.appearance.accent = v));
heading(&list, "Kant & skugga");
row(&list, "Kantfärg", &color_button(ctx, &a.border.color, |c, v| c.appearance.border.color = v));
row(&list, "Kantopacitet", &scale01(ctx, a.border.opacity, |c, v| c.appearance.border.opacity = v));
row(&list, "Kantbredd", &spin(ctx, a.border.width as f64, (0.0, 8.0), 1.0, |c, v| c.appearance.border.width = v as u32));
row(&list, "Skugga", &switch(ctx, a.shadow.enabled, |c, v| c.appearance.shadow.enabled = v));
row(&list, "Skuggstorlek", &spin(ctx, a.shadow.blur as f64, (0.0, 60.0), 1.0, |c, v| c.appearance.shadow.blur = v as u32));
row(&list, "Skuggopacitet", &scale01(ctx, a.shadow.opacity, |c, v| c.appearance.shadow.opacity = v));
heading(&list, "Ikoner");
row(&list, "Ikonstorlek", &spin(ctx, a.icon_size as f64, (16.0, 96.0), 2.0, |c, v| c.appearance.icon_size = v as i32));
row(&list, "Ikonavstånd", &spin(ctx, a.icon_spacing as f64, (0.0, 32.0), 1.0, |c, v| c.appearance.icon_spacing = v as i32));
row(&list, "Panel-padding", &spin(ctx, a.padding as f64, (0.0, 32.0), 1.0, |c, v| c.appearance.padding = v as i32));
row(&list, "Hover-zoom", &switch(ctx, a.hover_zoom, |c, v| c.appearance.hover_zoom = v));
row(&list, "Animationer", &switch(ctx, a.animations, |c, v| c.appearance.animations = v));
let ind_idx = match a.indicator.style {
IndicatorStyle::Dot => 0,
IndicatorStyle::Line => 1,
IndicatorStyle::None => 2,
};
row(&list, "Indikatorstil", &dropdown(ctx, &["Prickar", "Linje", "Ingen"], ind_idx, |c, i| {
c.appearance.indicator.style = match i {
1 => IndicatorStyle::Line,
2 => IndicatorStyle::None,
_ => IndicatorStyle::Dot,
};
}));
row(&list, "Max indikatorer", &spin(ctx, a.indicator.max as f64, (1.0, 5.0), 1.0, |c, v| c.appearance.indicator.max = v as u32));
scroll
}
fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page();
let cfg = ctx.cfg.borrow().clone();
heading(&list, "Placering");
let pos_idx = match cfg.position {
Position::Bottom => 0,
Position::Top => 1,
Position::Left => 2,
Position::Right => 3,
};
row(&list, "Kant", &dropdown(ctx, &["Nere", "Uppe", "Vänster", "Höger"], pos_idx, |c, i| {
c.position = match i {
1 => Position::Top,
2 => Position::Left,
3 => Position::Right,
_ => Position::Bottom,
};
}));
row(&list, "Centrerad ö", &switch(ctx, cfg.centered, |c, v| c.centered = v));
row(&list, "Marginal från kant", &spin(ctx, cfg.margin as f64, (0.0, 64.0), 1.0, |c, v| c.margin = v as i32));
let layer_idx = match cfg.layer {
Layer::Top => 0,
Layer::Overlay => 1,
Layer::Bottom => 2,
};
row(&list, "Lager", &dropdown(ctx, &["Ovanpå fönster", "Alltid överst (overlay)", "Under fönster"], layer_idx, |c, i| {
c.layer = match i {
1 => Layer::Overlay,
2 => Layer::Bottom,
_ => Layer::Top,
};
}));
row(&list, "Reservera plats (undvik fönster)", &switch(ctx, cfg.avoid_windows, |c, v| c.avoid_windows = v));
heading(&list, "Autohide");
row(&list, "Aktiverad", &switch(ctx, cfg.autohide.enabled, |c, v| c.autohide.enabled = v));
let mode_idx = match cfg.autohide.mode {
AutohideMode::Dodge => 0,
AutohideMode::Always => 1,
};
row(&list, "Läge", &dropdown(ctx, &["Dodge (göm vid överlapp)", "Göm alltid"], mode_idx, |c, i| {
c.autohide.mode = if i == 1 { AutohideMode::Always } else { AutohideMode::Dodge };
}));
row(&list, "Fördröjning (ms)", &spin(ctx, cfg.autohide.hide_delay_ms as f64, (0.0, 3000.0), 50.0, |c, v| c.autohide.hide_delay_ms = v as u32));
row(&list, "Sensorbredd (px)", &spin(ctx, cfg.autohide.reveal_px as f64, (1.0, 16.0), 1.0, |c, v| c.autohide.reveal_px = v as i32));
row(&list, "Animationstid (ms)", &spin(ctx, cfg.autohide.animation_ms as f64, (0.0, 1000.0), 25.0, |c, v| c.autohide.animation_ms = v as u32));
let note = gtk::Label::new(Some("Per-skärm-inställningar (unika pins, only_this_output)\nredigeras i config.json under \"outputs\"."));
note.add_css_class("dim-label");
note.set_halign(gtk::Align::Start);
note.set_margin_top(12);
list.append(&note);
scroll
}
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();
heading(&list, "Klick");
let click_idx = match b.click {
ClickAction::FocusOrMinimize => 0,
ClickAction::Focus => 1,
ClickAction::Cycle => 2,
};
row(&list, "Vänsterklick", &dropdown(ctx, &["Fokusera/minimera", "Bara fokusera", "Växla fönster"], click_idx, |c, i| {
c.behavior.click = match i {
1 => ClickAction::Focus,
2 => ClickAction::Cycle,
_ => ClickAction::FocusOrMinimize,
};
}));
let mid_idx = match b.middle_click {
MiddleClick::LaunchNew => 0,
MiddleClick::Close => 1,
MiddleClick::None => 2,
};
row(&list, "Mittklick", &dropdown(ctx, &["Starta ny instans", "Stäng", "Inget"], mid_idx, |c, i| {
c.behavior.middle_click = match i {
1 => MiddleClick::Close,
2 => MiddleClick::None,
_ => MiddleClick::LaunchNew,
};
}));
let scroll_idx = match b.scroll {
ScrollAction::CycleWindows => 0,
ScrollAction::None => 1,
};
row(&list, "Skroll", &dropdown(ctx, &["Växla fönster", "Inget"], scroll_idx, |c, i| {
c.behavior.scroll = if i == 1 { ScrollAction::None } else { ScrollAction::CycleWindows };
}));
heading(&list, "Övrigt");
row(&list, "Gruppera appar", &switch(ctx, b.group_apps, |c, v| c.behavior.group_apps = v));
row(&list, "Tooltips", &switch(ctx, b.tooltips, |c, v| c.behavior.tooltips = v));
row(&list, "Urgency-puls", &switch(ctx, b.urgent_pulse, |c, v| c.behavior.urgent_pulse = v));
heading(&list, "Specialknappar");
row(&list, "Visa skrivbordet", &switch(ctx, sp.show_desktop, |c, v| c.special_buttons.show_desktop = v));
row(&list, "Översikt (scale)", &switch(ctx, sp.overview, |c, v| c.special_buttons.overview = v));
heading(&list, "Högerklicksmenyn");
let entries: [(&str, MenuItem); 5] = [
("Minimera", MenuItem::Minimize),
("Maximera", MenuItem::Maximize),
("Flytta till skärm", MenuItem::MoveToOutput),
("Pinna/släpp", MenuItem::Pin),
("Stäng", MenuItem::Close),
];
for (label, mi) in entries {
let present = ctx.cfg.borrow().context_menu.contains(&mi);
let mi2 = mi.clone();
row(&list, label, &switch(ctx, present, move |c, v| {
if v {
if !c.context_menu.contains(&mi2) {
c.context_menu.push(mi2.clone());
}
} else {
c.context_menu.retain(|m| *m != mi2);
}
}));
}
scroll
}
fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page();
heading(&list, "Globala pins (.desktop-id, i ordning)");
let pins_box = gtk::Box::new(gtk::Orientation::Vertical, 4);
list.append(&pins_box);
fn rebuild_rows(ctx: &Rc<Ctx>, pins_box: &gtk::Box) {
while let Some(c) = pins_box.first_child() {
pins_box.remove(&c);
}
let pins = ctx.cfg.borrow().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)
.map(|m| m.display_name)
.unwrap_or_else(|| "⚠ hittas inte".into());
let lbl = gtk::Label::new(Some(&format!("{pin} ({name})")));
lbl.set_halign(gtk::Align::Start);
lbl.set_hexpand(true);
hbox.append(&lbl);
let up = gtk::Button::from_icon_name("go-up-symbolic");
up.add_css_class("flat");
let remove = gtk::Button::from_icon_name("edit-delete-symbolic");
remove.add_css_class("flat");
hbox.append(&up);
hbox.append(&remove);
pins_box.append(&hbox);
let ctx2 = ctx.clone();
let pb2 = pins_box.clone();
up.connect_clicked(move |_| {
if i > 0 {
commit(&ctx2, |c| c.pins.swap(i, i - 1));
rebuild_rows(&ctx2, &pb2);
}
});
let ctx3 = ctx.clone();
let pb3 = pins_box.clone();
remove.connect_clicked(move |_| {
commit(&ctx3, |c| {
c.pins.remove(i);
});
rebuild_rows(&ctx3, &pb3);
});
}
}
rebuild_rows(ctx, &pins_box);
let add_box = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("t.ex. org.kde.konsole"));
entry.set_hexpand(true);
let add = gtk::Button::with_label("Lägg till");
add_box.append(&entry);
add_box.append(&add);
list.append(&add_box);
{
let ctx = ctx.clone();
let pins_box = pins_box.clone();
add.connect_clicked(move |_| {
let id = entry.text().trim().to_string();
if id.is_empty() {
return;
}
if crate::icons::lookup_pin(&id).is_none() {
entry.add_css_class("error");
return;
}
entry.remove_css_class("error");
entry.set_text("");
let id2 = id.clone();
commit(&ctx, move |c| {
if !c.pins.contains(&id2) {
c.pins.push(id2.clone());
}
});
rebuild_rows(&ctx, &pins_box);
});
}
let note = gtk::Label::new(Some("Tips: hitta id med ls /usr/share/applications | grep -i <app>\nPer-skärm-pins redigeras i config.json under \"outputs\"."));
note.add_css_class("dim-label");
note.set_halign(gtk::Align::Start);
note.set_margin_top(12);
list.append(&note);
scroll
}
fn build_hotspots_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page();
let h = ctx.cfg.borrow().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));
row(&list, "Uppe höger", &hotspot_entry(ctx, &h.corners.top_right, |c, v| c.hotspots.corners.top_right = v));
row(&list, "Nere vänster", &hotspot_entry(ctx, &h.corners.bottom_left, |c, v| c.hotspots.corners.bottom_left = v));
row(&list, "Nere höger", &hotspot_entry(ctx, &h.corners.bottom_right, |c, v| c.hotspots.corners.bottom_right = v));
heading(&list, "Hot edges");
row(&list, "Uppe", &hotspot_entry(ctx, &h.edges.top, |c, v| c.hotspots.edges.top = v));
row(&list, "Nere", &hotspot_entry(ctx, &h.edges.bottom, |c, v| c.hotspots.edges.bottom = v));
row(&list, "Vänster", &hotspot_entry(ctx, &h.edges.left, |c, v| c.hotspots.edges.left = v));
row(&list, "Höger", &hotspot_entry(ctx, &h.edges.right, |c, v| c.hotspots.edges.right = v));
row(&list, "Dwell-tid (ms)", &spin(ctx, h.dwell_ms as f64, (0.0, 2000.0), 50.0, |c, v| c.hotspots.dwell_ms = v as u32));
let note = gtk::Label::new(Some("Format: exec:<kommando> eller ipc:<metod> (t.ex. ipc:scale/toggle).\nTomt fält = av. IPC kräver Wayfires ipc-plugin."));
note.add_css_class("dim-label");
note.set_halign(gtk::Align::Start);
note.set_margin_top(12);
list.append(&note);
scroll
}
/// Ö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()));
if let Some(win) = existing {
win.present();
return;
}
let win = gtk::Window::new();
win.set_title(Some("waydock — inställningar"));
win.set_default_size(520, 620);
let notebook = gtk::Notebook::new();
notebook.append_page(&build_appearance_page(ctx), Some(&gtk::Label::new(Some("Utseende"))));
notebook.append_page(&build_layout_page(ctx), Some(&gtk::Label::new(Some("Layout"))));
notebook.append_page(&build_behavior_page(ctx), Some(&gtk::Label::new(Some("Beteende"))));
notebook.append_page(&build_pins_page(ctx), Some(&gtk::Label::new(Some("Pins"))));
notebook.append_page(&build_hotspots_page(ctx), Some(&gtk::Label::new(Some("Hotspots"))));
win.set_child(Some(&notebook));
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>) {}

180
src/style.rs Normal file
View File

@@ -0,0 +1,180 @@
// 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.
use gtk4 as gtk;
use gtk4::gdk;
use crate::config::{Config, IndicatorStyle};
/// "#rrggbb" + alpha → "rgba(r, g, b, a)". Trasig färg → grå.
fn rgba(hex: &str, alpha: f64) -> String {
let h = hex.trim_start_matches('#');
let (r, g, b) = if h.len() == 6 {
(
u8::from_str_radix(&h[0..2], 16).unwrap_or(0x88),
u8::from_str_radix(&h[2..4], 16).unwrap_or(0x88),
u8::from_str_radix(&h[4..6], 16).unwrap_or(0x88),
)
} else {
(0x88, 0x88, 0x88)
};
format!("rgba({r}, {g}, {b}, {alpha:.3})")
}
pub fn build_css(cfg: &Config) -> String {
let a = &cfg.appearance;
let bg = rgba(&a.background, a.opacity);
let border = rgba(&a.border.color, a.border.opacity);
let accent = rgba(&a.accent, 1.0);
let accent_soft = rgba(&a.accent, 0.28);
let shadow = if a.shadow.enabled {
format!(
"box-shadow: 0 2px {}px {};",
a.shadow.blur,
rgba("#000000", a.shadow.opacity)
)
} else {
String::new()
};
let transition = if a.animations {
"transition: all 150ms ease;"
} else {
"transition: none;"
};
let hover_zoom = if a.hover_zoom { "-gtk-icon-transform: scale(1.15);" } else { "" };
let item_pad = (a.icon_spacing / 2).max(0);
let (ind_w, ind_h, ind_radius) = match a.indicator.style {
IndicatorStyle::Dot => (5, 5, 3),
IndicatorStyle::Line => (16, 3, 2),
IndicatorStyle::None => (0, 0, 0),
};
format!(
r#"
window.waydock {{
background: transparent;
}}
.dock-panel {{
background-color: {bg};
border-radius: {radius}px;
border: {bwidth}px solid {border};
padding: {padding}px;
{shadow}
}}
.dock-item {{
background: transparent;
border: none;
border-radius: {item_radius}px;
padding: 4px;
margin: 0 {item_pad}px;
min-width: {icon}px;
min-height: {icon}px;
{transition}
}}
.dock-item:hover {{
background-color: {accent_soft};
}}
.dock-item:hover image {{
{hover_zoom}
}}
.dock-item image {{
{transition}
}}
.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 {{
min-height: {ind_h}px;
}}
.indicator {{
background-color: {accent};
border-radius: {ind_radius}px;
min-width: {ind_w}px;
min-height: {ind_h}px;
margin: 0 1px;
}}
.indicator.inactive {{
background-color: {accent_dim};
}}
.group-badge {{
background-color: {accent};
color: #ffffff;
border-radius: 8px;
font-size: 9px;
font-weight: bold;
min-width: 14px;
min-height: 14px;
padding: 0 2px;
}}
popover.waydock-menu contents {{
background-color: {menu_bg};
border-radius: 10px;
border: 1px solid {border};
}}
popover.waydock-menu modelbutton,
popover.waydock-menu button.flat {{
color: #eeeeee;
border-radius: 6px;
padding: 6px 10px;
}}
popover.waydock-menu modelbutton:hover,
popover.waydock-menu button.flat:hover {{
background-color: {accent_soft};
}}
"#,
bg = bg,
radius = a.corner_radius,
bwidth = a.border.width,
border = border,
padding = a.padding,
shadow = shadow,
item_radius = (a.corner_radius as i32 - 4).max(4),
item_pad = item_pad,
icon = a.icon_size + 8,
transition = transition,
hover_zoom = hover_zoom,
accent = accent,
accent_soft = accent_soft,
accent_dim = rgba(&a.accent, 0.45),
ind_h = ind_h,
ind_w = ind_w,
ind_radius = ind_radius,
menu_bg = rgba(&a.background, (a.opacity + 0.2).min(1.0)),
)
}
/// Ladda (eller byt ut) den globala CSS-providern.
pub fn apply(cfg: &Config, previous: Option<&gtk::CssProvider>) -> gtk::CssProvider {
let display = gdk::Display::default().expect("ingen display");
if let Some(old) = previous {
gtk::style_context_remove_provider_for_display(&display, old);
}
let provider = gtk::CssProvider::new();
provider.load_from_string(&build_css(cfg));
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
provider
}

413
src/toplevel.rs Normal file
View File

@@ -0,0 +1,413 @@
// Egen Wayland-anslutning som talar zwlr_foreign_toplevel_manager_v1:
// fönsterlista + states in, aktivera/minimera/maximera/stäng ut.
// Anslutningens fd hängs in i GLib-mainloopen — helt händelsestyrt.
use std::cell::RefCell;
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::rc::Rc;
use gtk4::glib;
use log::{debug, info, warn};
use wayland_client::{
backend::ObjectId,
event_created_child,
globals::{registry_queue_init, GlobalListContents},
protocol::{wl_output, wl_registry, wl_seat},
Connection, Dispatch, EventQueue, Proxy, QueueHandle,
};
use wayland_protocols_wlr::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1},
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
};
/// Snapshot av ett fönster som UI:t jobbar med.
#[derive(Clone)]
pub struct ToplevelInfo {
pub id: ObjectId,
pub handle: ZwlrForeignToplevelHandleV1,
pub app_id: String,
pub title: String,
pub minimized: bool,
pub maximized: bool,
pub activated: bool,
pub outputs: Vec<String>,
}
struct ToplevelEntry {
handle: ZwlrForeignToplevelHandleV1,
app_id: String,
title: String,
minimized: bool,
maximized: bool,
activated: bool,
outputs: Vec<ObjectId>,
initialized: bool, // första done har kommit
}
struct OutputEntry {
global_name: u32,
name: Option<String>,
}
pub struct WlState {
toplevels: Vec<(ObjectId, ToplevelEntry)>, // ordning = skapelseordning
outputs: HashMap<ObjectId, OutputEntry>,
seat: Option<wl_seat::WlSeat>,
dirty: bool,
}
impl WlState {
fn entry_mut(&mut self, id: &ObjectId) -> Option<&mut ToplevelEntry> {
self.toplevels
.iter_mut()
.find(|(tid, _)| tid == id)
.map(|(_, e)| e)
}
fn snapshot(&self) -> Vec<ToplevelInfo> {
self.toplevels
.iter()
.filter(|(_, e)| e.initialized)
.map(|(id, e)| ToplevelInfo {
id: id.clone(),
handle: e.handle.clone(),
app_id: e.app_id.clone(),
title: e.title.clone(),
minimized: e.minimized,
maximized: e.maximized,
activated: e.activated,
outputs: e
.outputs
.iter()
.filter_map(|oid| self.outputs.get(oid).and_then(|o| o.name.clone()))
.collect(),
})
.collect()
}
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for WlState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global { name, interface, version } => {
if interface == "wl_output" {
let output: wl_output::WlOutput =
registry.bind(name, version.min(4), qh, ());
state.outputs.insert(
output.id(),
OutputEntry { global_name: name, name: None },
);
}
}
wl_registry::Event::GlobalRemove { name } => {
state.outputs.retain(|_, o| o.global_name != name);
state.dirty = true;
}
_ => {}
}
}
}
impl Dispatch<wl_output::WlOutput, ()> for WlState {
fn event(
state: &mut Self,
output: &wl_output::WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
debug!("output: {name}");
if let Some(entry) = state.outputs.get_mut(&output.id()) {
entry.name = Some(name);
state.dirty = true;
}
}
}
}
impl Dispatch<wl_seat::WlSeat, ()> for WlState {
fn event(
_: &mut Self,
_: &wl_seat::WlSeat,
_: wl_seat::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for WlState {
fn event(
state: &mut Self,
_: &ZwlrForeignToplevelManagerV1,
event: zwlr_foreign_toplevel_manager_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } => {
state.toplevels.push((
toplevel.id(),
ToplevelEntry {
handle: toplevel,
app_id: String::new(),
title: String::new(),
minimized: false,
maximized: false,
activated: false,
outputs: Vec::new(),
initialized: false,
},
));
}
zwlr_foreign_toplevel_manager_v1::Event::Finished => {
warn!("compositorn stängde foreign-toplevel-strömmen");
}
_ => {}
}
}
event_created_child!(WlState, ZwlrForeignToplevelManagerV1, [
zwlr_foreign_toplevel_manager_v1::EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()),
]);
}
impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for WlState {
fn event(
state: &mut Self,
handle: &ZwlrForeignToplevelHandleV1,
event: zwlr_foreign_toplevel_handle_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
use zwlr_foreign_toplevel_handle_v1::Event;
let id = handle.id();
match event {
Event::AppId { app_id } => {
if let Some(e) = state.entry_mut(&id) {
e.app_id = app_id;
}
}
Event::Title { title } => {
if let Some(e) = state.entry_mut(&id) {
e.title = title;
}
}
Event::OutputEnter { output } => {
if let Some(e) = state.entry_mut(&id) {
e.outputs.push(output.id());
}
}
Event::OutputLeave { output } => {
if let Some(e) = state.entry_mut(&id) {
e.outputs.retain(|o| *o != output.id());
}
}
Event::State { state: raw } => {
use zwlr_foreign_toplevel_handle_v1::State as S;
let mut min = false;
let mut max = false;
let mut act = false;
for chunk in raw.chunks_exact(4) {
let v = u32::from_ne_bytes(chunk.try_into().unwrap());
match S::try_from(v) {
Ok(S::Minimized) => min = true,
Ok(S::Maximized) => max = true,
Ok(S::Activated) => act = true,
_ => {}
}
}
if let Some(e) = state.entry_mut(&id) {
e.minimized = min;
e.maximized = max;
e.activated = act;
}
}
Event::Done => {
if let Some(e) = state.entry_mut(&id) {
e.initialized = true;
}
state.dirty = true;
}
Event::Closed => {
state.toplevels.retain(|(tid, _)| *tid != id);
handle.destroy();
state.dirty = true;
}
_ => {}
}
}
}
/// Handtag som UI:t använder för att läsa fönsterlistan och skicka requests.
pub struct ToplevelService {
state: Rc<RefCell<WlState>>,
queue: Rc<RefCell<EventQueue<WlState>>>,
/// fönster som "visa skrivbord" minimerade, för återställning
desktop_stash: RefCell<Vec<ObjectId>>,
}
impl ToplevelService {
pub fn snapshot(&self) -> Vec<ToplevelInfo> {
self.state.borrow().snapshot()
}
fn flush(&self) {
if let Err(e) = self.queue.borrow().flush() {
warn!("wayland-flush misslyckades: {e}");
}
}
pub fn activate(&self, info: &ToplevelInfo) {
if let Some(seat) = self.state.borrow().seat.as_ref() {
info.handle.activate(seat);
}
self.flush();
}
pub fn set_minimized(&self, info: &ToplevelInfo, min: bool) {
if min {
info.handle.set_minimized();
} else {
info.handle.unset_minimized();
}
self.flush();
}
pub fn set_maximized(&self, info: &ToplevelInfo, max: bool) {
if max {
info.handle.set_maximized();
} else {
info.handle.unset_maximized();
}
self.flush();
}
pub fn close(&self, info: &ToplevelInfo) {
info.handle.close();
self.flush();
}
/// Visa skrivbord: minimera allt; andra klicket återställer det vi gömde.
pub fn toggle_show_desktop(&self) {
let snap = self.snapshot();
let mut stash = self.desktop_stash.borrow_mut();
let visible: Vec<&ToplevelInfo> = snap.iter().filter(|t| !t.minimized).collect();
if visible.is_empty() && !stash.is_empty() {
for t in &snap {
if stash.contains(&t.id) {
t.handle.unset_minimized();
}
}
stash.clear();
} else {
*stash = visible.iter().map(|t| t.id.clone()).collect();
for t in visible {
t.handle.set_minimized();
}
}
drop(stash);
self.flush();
}
}
/// Starta tjänsten och häng in fd:n i GLib-mainloopen.
/// `on_update` körs varje gång fönsterlistan ändrats.
pub fn start(on_update: Rc<dyn Fn()>) -> Result<Rc<ToplevelService>, String> {
let conn = Connection::connect_to_env().map_err(|e| format!("wayland: {e}"))?;
let (globals, mut queue) =
registry_queue_init::<WlState>(&conn).map_err(|e| format!("registry: {e}"))?;
let qh = queue.handle();
let mut state = WlState {
toplevels: Vec::new(),
outputs: HashMap::new(),
seat: None,
dirty: false,
};
// befintliga outputs + seat binds här; nya outputs kommer via registry-events
globals.contents().with_list(|list| {
for g in list {
if g.interface == "wl_output" {
let output: wl_output::WlOutput =
globals.registry().bind(g.name, g.version.min(4), &qh, ());
state
.outputs
.insert(output.id(), OutputEntry { global_name: g.name, name: None });
}
}
});
state.seat = globals.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=9, ()).ok();
let _manager: ZwlrForeignToplevelManagerV1 = globals
.bind(&qh, 1..=3, ())
.map_err(|e| format!("compositorn saknar zwlr_foreign_toplevel_manager_v1: {e}"))?;
queue
.roundtrip(&mut state)
.map_err(|e| format!("roundtrip: {e}"))?;
// en runda till så namn/states från nyskapade objekt hinner in
queue
.roundtrip(&mut state)
.map_err(|e| format!("roundtrip 2: {e}"))?;
state.dirty = false;
info!(
"toplevel-tjänst uppe: {} fönster, {} outputs",
state.toplevels.len(),
state.outputs.len()
);
let state = Rc::new(RefCell::new(state));
let queue = Rc::new(RefCell::new(queue));
let service = Rc::new(ToplevelService {
state: state.clone(),
queue: queue.clone(),
desktop_stash: RefCell::new(Vec::new()),
});
let fd = conn.as_fd().as_raw_fd();
{
let conn = conn.clone();
let state = state.clone();
let queue = queue.clone();
glib::source::unix_fd_add_local(fd, glib::IOCondition::IN, move |_, _| {
{
let mut st = state.borrow_mut();
let mut q = queue.borrow_mut();
if let Some(guard) = conn.prepare_read() {
let _ = guard.read();
}
if let Err(e) = q.dispatch_pending(&mut st) {
warn!("wayland-dispatch: {e}");
}
let _ = q.flush();
}
let is_dirty = {
let mut st = state.borrow_mut();
std::mem::take(&mut st.dirty)
};
if is_dirty {
on_update();
}
glib::ControlFlow::Continue
});
}
// initial UI-uppdatering
Ok(service)
}

129
src/wayfire_ipc.rs Normal file
View File

@@ -0,0 +1,129 @@
// Minimal synkron klient mot Wayfires IPC-socket ($WAYFIRE_SOCKET).
// Protokoll: 4 byte längd (LE) + JSON, åt båda hållen.
// Används för scale/expo-toggle, flytta-fönster-till-skärm och
// (för dodge) fönstergeometri. Allt är frivilligt — saknas socketen
// göms funktionerna som behöver den.
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
use log::{debug, warn};
use serde_json::{json, Value};
pub fn socket_path() -> Option<String> {
if let Ok(p) = std::env::var("WAYFIRE_SOCKET") {
return Some(p);
}
// ipc-pluginen aktiverad efter sessionsstart → env saknas men
// socketen ligger på standardvägen /tmp/wayfire-<display>.socket
let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-1".into());
let fallback = format!("/tmp/wayfire-{display}.socket");
std::path::Path::new(&fallback).exists().then_some(fallback)
}
pub fn available() -> bool {
socket_path().is_some()
}
/// Skicka ett IPC-anrop och vänta (kort) på svar.
pub fn call(method: &str, data: Value) -> Result<Value, String> {
let path = socket_path().ok_or("WAYFIRE_SOCKET saknas")?;
let mut stream = UnixStream::connect(&path).map_err(|e| format!("connect: {e}"))?;
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.ok();
stream
.set_write_timeout(Some(Duration::from_millis(500)))
.ok();
let mut msg = json!({ "method": method });
if !data.is_null() {
msg["data"] = data;
}
let payload = serde_json::to_vec(&msg).map_err(|e| e.to_string())?;
let len = (payload.len() as u32).to_le_bytes();
stream.write_all(&len).map_err(|e| format!("write: {e}"))?;
stream
.write_all(&payload)
.map_err(|e| format!("write: {e}"))?;
let mut len_buf = [0u8; 4];
stream
.read_exact(&mut len_buf)
.map_err(|e| format!("read len: {e}"))?;
let n = u32::from_le_bytes(len_buf) as usize;
if n > 16 * 1024 * 1024 {
return Err("orimligt stort IPC-svar".into());
}
let mut buf = vec![0u8; n];
stream
.read_exact(&mut buf)
.map_err(|e| format!("read: {e}"))?;
let value: Value = serde_json::from_slice(&buf).map_err(|e| e.to_string())?;
debug!("ipc {method} -> {value}");
Ok(value)
}
fn call_logged(method: &str, data: Value) {
if let Err(e) = call(method, data) {
warn!("wayfire-ipc {method}: {e}");
}
}
/// Toggla scale-vyn (Exposé) — på alla skärmar om `all` är satt.
pub fn toggle_scale() {
call_logged("scale/toggle", Value::Null);
}
#[allow(dead_code)] // används av hotspots (M6)
pub fn toggle_expo() {
call_logged("expo/toggle", Value::Null);
}
/// Lista Wayfires vyer (kräver ipc-rules-pluginen). Ger geometri m.m.
pub fn list_views() -> Option<Vec<Value>> {
call("window-rules/list-views", Value::Null)
.ok()
.and_then(|v| v.as_array().cloned())
}
pub fn list_outputs() -> Option<Vec<Value>> {
call("window-rules/list-outputs", Value::Null)
.ok()
.and_then(|v| v.as_array().cloned())
}
/// Hitta Wayfire-view-id för (app_id, title) — bästa möjliga matchning
/// eftersom wlr-protokollet och IPC:n inte delar id:n.
pub fn find_view_id(app_id: &str, title: &str) -> Option<i64> {
let views = list_views()?;
let matches: Vec<&Value> = views
.iter()
.filter(|v| {
v["app-id"].as_str() == Some(app_id)
&& (v["title"].as_str() == Some(title) || title.is_empty())
})
.collect();
matches.first().and_then(|v| v["id"].as_i64())
}
/// Flytta en vy till en annan output (namn t.ex. "DP-1").
pub fn move_view_to_output(view_id: i64, output_name: &str) {
let Some(outputs) = list_outputs() else {
warn!("wayfire-ipc: kunde inte lista outputs");
return;
};
let Some(output_id) = outputs
.iter()
.find(|o| o["name"].as_str() == Some(output_name))
.and_then(|o| o["id"].as_i64())
else {
warn!("wayfire-ipc: okänd output {output_name}");
return;
};
call_logged(
"window-rules/configure-view",
json!({ "id": view_id, "output_id": output_id }),
);
}