feat: initial cosmic-gtk-config bridge
Daemon user-mode qui relie le setting CosmicTk.window_controls_position
aux fenêtres non-libcosmic :
- met à jour gtk-decoration-layout dans ~/.config/gtk-{3,4}.0/settings.ini
- aligne org.gnome.desktop.wm.preferences button-layout via gsettings
- abonné via Config::watch (cosmic-config) — propagation à chaud
Pendant : kde-gtk-config / Plasma. Limites connues : Firefox + apps
Electron qui dessinent leur propre titlebar (phase 2).
By Leyoda 2026 — MIT
This commit is contained in:
commit
a61c95040e
5 changed files with 249 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
*.bak
|
||||
*.tmp
|
||||
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "cosmic-gtk-config"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Bridges the COSMIC window-controls-position setting to GTK3, GTK4 and gsettings, à la kde-gtk-config."
|
||||
authors = ["Lionel Darnis <ldarnis@gmail.com>"]
|
||||
|
||||
[dependencies]
|
||||
cosmic-config = { path = "../libcosmic/cosmic-config", default-features = false, features = ["macro"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lionel Darnis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
13
services/cosmic-gtk-config.service
Normal file
13
services/cosmic-gtk-config.service
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=COSMIC -> GTK config bridge (window controls position, theme...)
|
||||
PartOf=cosmic-session.target
|
||||
After=cosmic-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/cosmic-gtk-config
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=cosmic-session.target
|
||||
192
src/main.rs
Normal file
192
src/main.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cosmic_config::{Config, ConfigGet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const COSMIC_TK_ID: &str = "com.system76.CosmicTk";
|
||||
const COSMIC_TK_VERSION: u64 = 1;
|
||||
const KEY: &str = "window_controls_position";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
enum WindowControlsPosition {
|
||||
Start,
|
||||
#[default]
|
||||
End,
|
||||
}
|
||||
|
||||
impl WindowControlsPosition {
|
||||
fn gtk_layout(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "close,minimize,maximize:",
|
||||
Self::End => "menu:minimize,maximize,close",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn home_path(rel: &str) -> PathBuf {
|
||||
let home = std::env::var_os("HOME").expect("HOME unset");
|
||||
PathBuf::from(home).join(rel)
|
||||
}
|
||||
|
||||
fn rewrite_decoration_layout(content: &str, layout: &str) -> String {
|
||||
let mut found = false;
|
||||
let mut out = String::with_capacity(content.len() + 64);
|
||||
let trailing_newline = content.ends_with('\n');
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("gtk-decoration-layout=") {
|
||||
out.push_str("gtk-decoration-layout=");
|
||||
out.push_str(layout);
|
||||
out.push('\n');
|
||||
found = true;
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
if !out.contains("[Settings]") {
|
||||
out.push_str("[Settings]\n");
|
||||
}
|
||||
out.push_str("gtk-decoration-layout=");
|
||||
out.push_str(layout);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
if !trailing_newline && !out.is_empty() {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn update_gtk_ini(path: &Path, layout: &str) -> Result<()> {
|
||||
let content = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read {}", path.display()))?
|
||||
} else {
|
||||
String::from("[Settings]\n")
|
||||
};
|
||||
|
||||
let new_content = rewrite_decoration_layout(&content, layout);
|
||||
if new_content == content {
|
||||
log::debug!("{}: already up-to-date", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let tmp = path.with_extension("ini.tmp");
|
||||
std::fs::write(&tmp, &new_content)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("rename to {}", path.display()))?;
|
||||
|
||||
log::info!("updated {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_gsettings(layout: &str) -> Result<()> {
|
||||
let status = Command::new("gsettings")
|
||||
.args([
|
||||
"set",
|
||||
"org.gnome.desktop.wm.preferences",
|
||||
"button-layout",
|
||||
layout,
|
||||
])
|
||||
.status()
|
||||
.context("spawn gsettings")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("gsettings exited with {}", status);
|
||||
}
|
||||
log::info!("gsettings button-layout = {}", layout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply(pos: WindowControlsPosition) -> Result<()> {
|
||||
let layout = pos.gtk_layout();
|
||||
log::info!("applying position={:?} layout={}", pos, layout);
|
||||
update_gtk_ini(&home_path(".config/gtk-3.0/settings.ini"), layout)?;
|
||||
update_gtk_ini(&home_path(".config/gtk-4.0/settings.ini"), layout)?;
|
||||
update_gsettings(layout)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_pos(config: &Config) -> WindowControlsPosition {
|
||||
config
|
||||
.get::<WindowControlsPosition>(KEY)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info"),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::new(COSMIC_TK_ID, COSMIC_TK_VERSION)
|
||||
.context("open com.system76.CosmicTk config")?;
|
||||
|
||||
let initial = read_pos(&config);
|
||||
log::info!("initial position: {:?}", initial);
|
||||
apply(initial).context("initial apply failed")?;
|
||||
|
||||
let _watcher = config
|
||||
.watch(|cfg, keys| {
|
||||
if !keys.iter().any(|k| k == KEY) {
|
||||
return;
|
||||
}
|
||||
let pos = cfg
|
||||
.get::<WindowControlsPosition>(KEY)
|
||||
.unwrap_or_default();
|
||||
log::info!("change detected: {:?}", pos);
|
||||
if let Err(err) = apply(pos) {
|
||||
log::error!("apply failed: {:#}", err);
|
||||
}
|
||||
})
|
||||
.context("setup watcher")?;
|
||||
|
||||
log::info!("daemon running, watching {}", COSMIC_TK_ID);
|
||||
std::thread::park();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rewrite_replaces_existing_line() {
|
||||
let input = "[Settings]\ngtk-theme-name=Adwaita\ngtk-decoration-layout=close,minimize,maximize:\ngtk-font-name=Sans 10\n";
|
||||
let out = rewrite_decoration_layout(input, "menu:minimize,maximize,close");
|
||||
assert!(out.contains("gtk-decoration-layout=menu:minimize,maximize,close\n"));
|
||||
assert!(!out.contains("close,minimize,maximize:"));
|
||||
assert!(out.contains("gtk-theme-name=Adwaita"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_appends_when_absent() {
|
||||
let input = "[Settings]\ngtk-theme-name=Adwaita\n";
|
||||
let out = rewrite_decoration_layout(input, "close,minimize,maximize:");
|
||||
assert!(out.contains("gtk-decoration-layout=close,minimize,maximize:\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_creates_section_when_empty() {
|
||||
let input = "";
|
||||
let out = rewrite_decoration_layout(input, "close,minimize,maximize:");
|
||||
assert!(out.contains("[Settings]"));
|
||||
assert!(out.contains("gtk-decoration-layout=close,minimize,maximize:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_preserves_no_trailing_newline() {
|
||||
let input = "[Settings]\ngtk-decoration-layout=:minimize,maximize,close";
|
||||
let out = rewrite_decoration_layout(input, "close,minimize,maximize:");
|
||||
assert!(!out.ends_with('\n'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue