waydock v0.1: panel, pins, fönstergrupper, menyer, settings-GUI, live-config
Some checks failed
check / check (push) Failing after 52s
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:
434
src/config.rs
Normal file
434
src/config.rs
Normal 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
402
src/dock.rs
Normal 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: >k::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: >k::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
107
src/icons.rs
Normal 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
232
src/main.rs
Normal 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: >k::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
316
src/menu.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
// Kontextmenyer: högerklick 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: >k::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: >k::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: >k::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: >k::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
529
src/settings.rs
Normal 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: >k::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: >k::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(¬e);
|
||||
|
||||
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: >k::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(¬e);
|
||||
|
||||
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(¬e);
|
||||
|
||||
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(>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));
|
||||
|
||||
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
180
src/style.rs
Normal 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<>k::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
413
src/toplevel.rs
Normal 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
129
src/wayfire_ipc.rs
Normal 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 }),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user