Compare commits
1
Commits
main
..
5b7398de2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b7398de2c |
@@ -1,7 +1,5 @@
|
|||||||
height: 42
|
height: 42
|
||||||
dev: true
|
dev: true
|
||||||
buddy:
|
|
||||||
enable: true
|
|
||||||
window_title:
|
window_title:
|
||||||
enable: true
|
enable: true
|
||||||
vinyl:
|
vinyl:
|
||||||
@@ -18,17 +16,6 @@ notmuch:
|
|||||||
screenrec:
|
screenrec:
|
||||||
enable: true
|
enable: true
|
||||||
output_dir: "~/Videos/wl-screenrec"
|
output_dir: "~/Videos/wl-screenrec"
|
||||||
org:
|
|
||||||
enable: true
|
|
||||||
paths:
|
|
||||||
- "~/Documents/notes"
|
|
||||||
todo_keywords: ["TODO", "NEXT", "IN-PROGRESS"]
|
|
||||||
done_keywords: ["DONE", "CANCELLED"]
|
|
||||||
counted_states: ["TODO", "NEXT", "IN-PROGRESS"]
|
|
||||||
update_interval: 60000
|
|
||||||
emacsclient_command: "emacsclient"
|
|
||||||
dropdown_width: 420
|
|
||||||
dropdown_height: 480
|
|
||||||
notifications:
|
notifications:
|
||||||
enable: true
|
enable: true
|
||||||
anchor: "top center"
|
anchor: "top center"
|
||||||
|
|||||||
@@ -45,385 +45,250 @@
|
|||||||
// {
|
// {
|
||||||
homeManagerModules = {
|
homeManagerModules = {
|
||||||
sims =
|
sims =
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
lib,
|
lib,
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.services.sims;
|
cfg = config.services.sims;
|
||||||
|
|
||||||
settingsFormat = pkgs.formats.yaml { };
|
settingsFormat = pkgs.formats.yaml { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.sims = {
|
options.services.sims = {
|
||||||
enable = lib.mkEnableOption "sims status bar";
|
enable = lib.mkEnableOption "sims status bar";
|
||||||
|
|
||||||
package = lib.mkOption {
|
package = lib.mkOption {
|
||||||
type = lib.types.package;
|
type = lib.types.package;
|
||||||
default = self.packages.${pkgs.system}.default;
|
default = self.packages.${pkgs.system}.default;
|
||||||
description = "The sims package to use.";
|
description = "The sims package to use.";
|
||||||
};
|
};
|
||||||
|
|
||||||
settings = lib.mkOption {
|
settings = lib.mkOption {
|
||||||
type = lib.types.submodule {
|
type = lib.types.submodule {
|
||||||
options = {
|
options = {
|
||||||
vinyl = {
|
vinyl = {
|
||||||
enable = lib.mkOption {
|
enable = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
};
|
|
||||||
};
|
|
||||||
darkman = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Add a light/dark mode toggle to the control center and the
|
|
||||||
`theme-toggle` / `theme-set-light` / `theme-set-dark` actions
|
|
||||||
(`sims-cli theme toggle|light|dark`). Switches darkman via its
|
|
||||||
control socket.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
socket_path = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
description = "darkman control socket; null means \${XDG_RUNTIME_DIR}/darkman/control.sock";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
battery = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buddy = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to enable the bar buddy (animated pixel-art slime that reacts to system state)";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
height = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 40;
|
|
||||||
description = "Height of the status bar in pixels";
|
|
||||||
};
|
|
||||||
logLevel = lib.mkOption {
|
|
||||||
type = lib.types.enum [
|
|
||||||
"TRACE"
|
|
||||||
"DEBUG"
|
|
||||||
"INFO"
|
|
||||||
"SUCCESS"
|
|
||||||
"WARNING"
|
|
||||||
"ERROR"
|
|
||||||
"CRITICAL"
|
|
||||||
];
|
|
||||||
default = "WARNING";
|
|
||||||
description = "Log level for the status bar (loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)";
|
|
||||||
};
|
|
||||||
window_title = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = "Whether to show the window title in the center of the bar";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
stylix = lib.mkOption {
|
|
||||||
type = lib.types.attrsOf lib.types.anything;
|
|
||||||
default = {
|
|
||||||
enable = false;
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Stylix configuration passed from the stylix module.
|
|
||||||
|
|
||||||
Optionally set `variants.light.colors` / `variants.dark.colors`
|
|
||||||
(base16 slots base00..base0F, hex without `#`). When present,
|
|
||||||
sims follows the XDG desktop portal's color-scheme setting
|
|
||||||
(org.freedesktop.appearance, e.g. served by darkman) and
|
|
||||||
swaps palettes live; slots missing from a variant fall back
|
|
||||||
to `colors`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
calendar = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = "Whether to enable the calendar widget";
|
|
||||||
};
|
|
||||||
khal_path = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "khal";
|
|
||||||
description = "Path to the khal binary";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
notmuch = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = "Whether to enable the notmuch email widget";
|
|
||||||
};
|
|
||||||
notmuch_path = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "notmuch";
|
|
||||||
description = "Path to the notmuch binary";
|
|
||||||
};
|
|
||||||
emacsclient_command = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "emacsclient";
|
|
||||||
description = "Path to the emacsclient binary";
|
|
||||||
};
|
|
||||||
debt_query = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "tag:unread and date:..1w";
|
|
||||||
description = "notmuch query whose count drives the mail-debt severity color on the bar widget";
|
|
||||||
};
|
|
||||||
debt_warn_at = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 1;
|
|
||||||
description = "Debt count at which the widget switches to the warn (orange) color";
|
|
||||||
};
|
|
||||||
debt_alarm_at = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 6;
|
|
||||||
description = "Debt count at which the widget switches to the alarm (red) color";
|
|
||||||
};
|
|
||||||
saved_searches = lib.mkOption {
|
|
||||||
type = lib.types.listOf (
|
|
||||||
lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
name = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "Display label shown in the search launcher";
|
|
||||||
};
|
|
||||||
query = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "notmuch query to run when this saved search is activated";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
default = [ ];
|
|
||||||
description = "Saved searches shown in the notmuch search launcher when the entry is empty";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
screenrec = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to enable the screenrec widget and menu";
|
|
||||||
};
|
|
||||||
output_dir = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "~/Videos/wl-screenrec";
|
|
||||||
description = "Directory to save recordings into";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
power = {
|
|
||||||
lock_command = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ "waylock" ];
|
|
||||||
description = "argv for the Lock action in the power menu";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
org = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to enable the org-mode agenda widget";
|
|
||||||
};
|
|
||||||
paths = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ ];
|
|
||||||
description = "Org files / directories / globs to scan (~ is expanded)";
|
|
||||||
};
|
|
||||||
todo_keywords = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [
|
|
||||||
"TODO"
|
|
||||||
"NEXT"
|
|
||||||
"IN-PROGRESS"
|
|
||||||
];
|
|
||||||
description = "Active TODO keywords used by orgparse when no #+TODO: header is present";
|
|
||||||
};
|
|
||||||
done_keywords = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [
|
|
||||||
"DONE"
|
|
||||||
"CANCELLED"
|
|
||||||
];
|
|
||||||
description = "Done keywords (filtered out of the count)";
|
|
||||||
};
|
|
||||||
counted_states = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [
|
|
||||||
"TODO"
|
|
||||||
"NEXT"
|
|
||||||
"IN-PROGRESS"
|
|
||||||
];
|
|
||||||
description = "States summed for the bar badge count";
|
|
||||||
};
|
|
||||||
update_interval = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 60000;
|
|
||||||
description = "Polling interval in milliseconds (mtime-checked, only re-parses on change)";
|
|
||||||
};
|
|
||||||
emacsclient_command = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "emacsclient";
|
|
||||||
description = "Command used to open headlines (split on whitespace)";
|
|
||||||
};
|
|
||||||
dropdown_width = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 420;
|
|
||||||
description = "Dropdown width in pixels";
|
|
||||||
};
|
|
||||||
dropdown_height = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 480;
|
|
||||||
description = "Dropdown height in pixels";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
notifications = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
|
|
||||||
};
|
|
||||||
anchor = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "top center";
|
|
||||||
description = "Layer-shell anchor for the toast stack";
|
|
||||||
};
|
|
||||||
margin = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "8px";
|
|
||||||
description = "Layer-shell margin for the toast stack";
|
|
||||||
};
|
|
||||||
width = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 360;
|
|
||||||
description = "Width of each notification toast in pixels";
|
|
||||||
};
|
|
||||||
timeout_ms = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 10000;
|
|
||||||
description = "Auto-close timeout for notifications in milliseconds";
|
|
||||||
};
|
|
||||||
history_size = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 50;
|
|
||||||
description = "How many past notifications the in-memory center keeps";
|
|
||||||
};
|
|
||||||
image_max_px = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 128;
|
|
||||||
description = "Max edge in pixels for stored notification thumbnails";
|
|
||||||
};
|
|
||||||
center_width = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 380;
|
|
||||||
description = "Width of the notification center side rail in pixels";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
battery = {
|
||||||
default = {
|
enable = lib.mkOption {
|
||||||
vinyl.enable = false;
|
type = lib.types.bool;
|
||||||
darkman.enable = false;
|
default = false;
|
||||||
battery.enable = false;
|
};
|
||||||
buddy.enable = false;
|
};
|
||||||
height = 40;
|
height = lib.mkOption {
|
||||||
logLevel = "WARNING";
|
type = lib.types.int;
|
||||||
window_title.enable = true;
|
default = 40;
|
||||||
stylix.enable = false;
|
description = "Height of the status bar in pixels";
|
||||||
|
};
|
||||||
|
logLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "TRACE" "DEBUG" "INFO" "SUCCESS" "WARNING" "ERROR" "CRITICAL" ];
|
||||||
|
default = "WARNING";
|
||||||
|
description = "Log level for the status bar (loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)";
|
||||||
|
};
|
||||||
|
window_title = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to show the window title in the center of the bar";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
stylix = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.anything;
|
||||||
|
default = { enable = false; };
|
||||||
|
description = "Stylix configuration passed from the stylix module";
|
||||||
|
};
|
||||||
calendar = {
|
calendar = {
|
||||||
enable = true;
|
enable = lib.mkOption {
|
||||||
khal_path = "khal";
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to enable the calendar widget";
|
||||||
|
};
|
||||||
|
khal_path = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "khal";
|
||||||
|
description = "Path to the khal binary";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
notmuch = {
|
notmuch = {
|
||||||
enable = true;
|
enable = lib.mkOption {
|
||||||
notmuch_path = "notmuch";
|
type = lib.types.bool;
|
||||||
emacsclient_command = "emacsclient";
|
default = true;
|
||||||
debt_query = "tag:unread and date:..1w";
|
description = "Whether to enable the notmuch email widget";
|
||||||
debt_warn_at = 1;
|
};
|
||||||
debt_alarm_at = 6;
|
notmuch_path = lib.mkOption {
|
||||||
saved_searches = [ ];
|
type = lib.types.str;
|
||||||
|
default = "notmuch";
|
||||||
|
description = "Path to the notmuch binary";
|
||||||
|
};
|
||||||
|
emacsclient_command = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "emacsclient";
|
||||||
|
description = "Path to the emacsclient binary";
|
||||||
|
};
|
||||||
|
debt_query = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "tag:unread and date:..1w";
|
||||||
|
description = "notmuch query whose count drives the mail-debt severity color on the bar widget";
|
||||||
|
};
|
||||||
|
debt_warn_at = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 1;
|
||||||
|
description = "Debt count at which the widget switches to the warn (orange) color";
|
||||||
|
};
|
||||||
|
debt_alarm_at = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 6;
|
||||||
|
description = "Debt count at which the widget switches to the alarm (red) color";
|
||||||
|
};
|
||||||
|
saved_searches = lib.mkOption {
|
||||||
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Display label shown in the search launcher";
|
||||||
|
};
|
||||||
|
query = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "notmuch query to run when this saved search is activated";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = [ ];
|
||||||
|
description = "Saved searches shown in the notmuch search launcher when the entry is empty";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
screenrec = {
|
screenrec = {
|
||||||
enable = false;
|
enable = lib.mkOption {
|
||||||
output_dir = "~/Videos/wl-screenrec";
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to enable the screenrec widget and menu";
|
||||||
|
};
|
||||||
|
output_dir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "~/Videos/wl-screenrec";
|
||||||
|
description = "Directory to save recordings into";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
power = {
|
power = {
|
||||||
lock_command = [ "waylock" ];
|
lock_command = lib.mkOption {
|
||||||
};
|
type = lib.types.listOf lib.types.str;
|
||||||
org = {
|
default = [ "waylock" ];
|
||||||
enable = false;
|
description = "argv for the Lock action in the power menu";
|
||||||
paths = [ ];
|
};
|
||||||
todo_keywords = [
|
|
||||||
"TODO"
|
|
||||||
"NEXT"
|
|
||||||
"IN-PROGRESS"
|
|
||||||
];
|
|
||||||
done_keywords = [
|
|
||||||
"DONE"
|
|
||||||
"CANCELLED"
|
|
||||||
];
|
|
||||||
counted_states = [
|
|
||||||
"TODO"
|
|
||||||
"NEXT"
|
|
||||||
"IN-PROGRESS"
|
|
||||||
];
|
|
||||||
update_interval = 60000;
|
|
||||||
emacsclient_command = "emacsclient";
|
|
||||||
dropdown_width = 420;
|
|
||||||
dropdown_height = 480;
|
|
||||||
};
|
};
|
||||||
notifications = {
|
notifications = {
|
||||||
enable = false;
|
enable = lib.mkOption {
|
||||||
anchor = "top center";
|
type = lib.types.bool;
|
||||||
margin = "8px";
|
default = false;
|
||||||
width = 360;
|
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
|
||||||
timeout_ms = 10000;
|
};
|
||||||
history_size = 50;
|
anchor = lib.mkOption {
|
||||||
image_max_px = 128;
|
type = lib.types.str;
|
||||||
center_width = 380;
|
default = "top center";
|
||||||
|
description = "Layer-shell anchor for the toast stack";
|
||||||
|
};
|
||||||
|
margin = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "8px";
|
||||||
|
description = "Layer-shell margin for the toast stack";
|
||||||
|
};
|
||||||
|
width = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 360;
|
||||||
|
description = "Width of each notification toast in pixels";
|
||||||
|
};
|
||||||
|
timeout_ms = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 10000;
|
||||||
|
description = "Auto-close timeout for notifications in milliseconds";
|
||||||
|
};
|
||||||
|
history_size = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 50;
|
||||||
|
description = "How many past notifications the in-memory center keeps";
|
||||||
|
};
|
||||||
|
image_max_px = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 128;
|
||||||
|
description = "Max edge in pixels for stored notification thumbnails";
|
||||||
|
};
|
||||||
|
center_width = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 380;
|
||||||
|
description = "Width of the notification center side rail in pixels";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
default = {
|
||||||
|
vinyl.enable = false;
|
||||||
config = lib.mkIf config.services.sims.enable {
|
battery.enable = false;
|
||||||
systemd.user.services.sims =
|
height = 40;
|
||||||
let
|
logLevel = "WARNING";
|
||||||
configFile = settingsFormat.generate "config.yaml" cfg.settings;
|
window_title.enable = true;
|
||||||
in
|
stylix.enable = false;
|
||||||
{
|
calendar = {
|
||||||
Unit = {
|
enable = true;
|
||||||
Description = "sims status bar";
|
khal_path = "khal";
|
||||||
# Start as part of the graphical session rather than at
|
|
||||||
# user-manager boot. The compositor populates the systemd
|
|
||||||
# user environment (WAYLAND_DISPLAY, NIXOS_OZONE_WL, ...)
|
|
||||||
# via `import-environment` before it activates
|
|
||||||
# graphical-session.target, so binding here guarantees sims
|
|
||||||
# — and every app it launches via `systemd-run --scope` —
|
|
||||||
# inherits the correct Wayland environment.
|
|
||||||
PartOf = [ "graphical-session.target" ];
|
|
||||||
After = [ "graphical-session.target" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
Service = {
|
|
||||||
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
|
|
||||||
Restart = "on-failure";
|
|
||||||
};
|
|
||||||
|
|
||||||
Install = {
|
|
||||||
WantedBy = [ "graphical-session.target" ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
notmuch = {
|
||||||
|
enable = true;
|
||||||
|
notmuch_path = "notmuch";
|
||||||
|
emacsclient_command = "emacsclient";
|
||||||
|
debt_query = "tag:unread and date:..1w";
|
||||||
|
debt_warn_at = 1;
|
||||||
|
debt_alarm_at = 6;
|
||||||
|
saved_searches = [ ];
|
||||||
|
};
|
||||||
|
screenrec = {
|
||||||
|
enable = false;
|
||||||
|
output_dir = "~/Videos/wl-screenrec";
|
||||||
|
};
|
||||||
|
power = {
|
||||||
|
lock_command = [ "waylock" ];
|
||||||
|
};
|
||||||
|
notifications = {
|
||||||
|
enable = false;
|
||||||
|
anchor = "top center";
|
||||||
|
margin = "8px";
|
||||||
|
width = 360;
|
||||||
|
timeout_ms = 10000;
|
||||||
|
history_size = 50;
|
||||||
|
image_max_px = 128;
|
||||||
|
center_width = 380;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf config.services.sims.enable {
|
||||||
|
systemd.user.services.sims =
|
||||||
|
let
|
||||||
|
configFile = settingsFormat.generate "config.yaml" cfg.settings;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
Unit = {
|
||||||
|
Description = "sims status bar";
|
||||||
|
After = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
Service = {
|
||||||
|
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
|
||||||
|
Restart = "on-failure";
|
||||||
|
};
|
||||||
|
|
||||||
|
Install = {
|
||||||
|
WantedBy = [ "default.target" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
stylix-sims = import ./nix/stylix/hm.nix;
|
stylix-sims = import ./nix/stylix/hm.nix;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ python3Packages.buildPythonApplication {
|
|||||||
pywayland
|
pywayland
|
||||||
pyyaml
|
pyyaml
|
||||||
platformdirs
|
platformdirs
|
||||||
orgparse
|
|
||||||
];
|
];
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
dontWrapGApps = true;
|
dontWrapGApps = true;
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ pkgs.mkShell {
|
|||||||
python-lsp-ruff
|
python-lsp-ruff
|
||||||
pyyaml
|
pyyaml
|
||||||
platformdirs
|
platformdirs
|
||||||
orgparse
|
|
||||||
]
|
]
|
||||||
))
|
))
|
||||||
];
|
];
|
||||||
|
|||||||
Executable → Regular
-90
@@ -3,12 +3,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@@ -93,15 +90,6 @@ def _cmd_corners(ns: argparse.Namespace) -> None:
|
|||||||
invoke_action(mapping[ns.corners_cmd])
|
invoke_action(mapping[ns.corners_cmd])
|
||||||
|
|
||||||
|
|
||||||
def _cmd_theme(ns: argparse.Namespace) -> None:
|
|
||||||
mapping = {
|
|
||||||
"toggle": "theme-toggle",
|
|
||||||
"light": "theme-set-light",
|
|
||||||
"dark": "theme-set-dark",
|
|
||||||
}
|
|
||||||
invoke_action(mapping[ns.theme_cmd])
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_list(ns: argparse.Namespace) -> None:
|
def _cmd_list(ns: argparse.Namespace) -> None:
|
||||||
actions = list_actions()
|
actions = list_actions()
|
||||||
if ns.json:
|
if ns.json:
|
||||||
@@ -116,61 +104,6 @@ def _cmd_invoke(ns: argparse.Namespace) -> None:
|
|||||||
invoke_action(ns.action)
|
invoke_action(ns.action)
|
||||||
|
|
||||||
|
|
||||||
def _cmd_dmenu(ns: argparse.Namespace) -> None:
|
|
||||||
"""dmenu-compatible picker: lines on stdin, chosen line on stdout.
|
|
||||||
|
|
||||||
Exits 1 when cancelled or when nothing was offered. Items travel via
|
|
||||||
a private request file instead of DBus arguments because dbus-send
|
|
||||||
splits array arguments on commas.
|
|
||||||
"""
|
|
||||||
lines = [line.rstrip("\r\n") for line in sys.stdin]
|
|
||||||
lines = [line for line in lines if line]
|
|
||||||
if not lines:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
base = os.environ.get("XDG_RUNTIME_DIR") or None
|
|
||||||
with tempfile.TemporaryDirectory(prefix="sims-dmenu-", dir=base) as request_dir:
|
|
||||||
with open(os.path.join(request_dir, "request.json"), "w", encoding="utf-8") as f:
|
|
||||||
json.dump({"prompt": ns.prompt, "lines": lines}, f)
|
|
||||||
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
|
||||||
server.bind(os.path.join(request_dir, "reply.sock"))
|
|
||||||
server.listen(1)
|
|
||||||
server.settimeout(ns.timeout)
|
|
||||||
|
|
||||||
out = _dbus_send(
|
|
||||||
f"{IFACE}.InvokeAction",
|
|
||||||
"string:open-dmenu",
|
|
||||||
f"array:string:{request_dir}",
|
|
||||||
)
|
|
||||||
# InvokeAction replies (error: bool, message: str) with exit 0
|
|
||||||
# even when the action does not exist (e.g. an older sims).
|
|
||||||
if re.search(r"^\s*boolean true\s*$", out, re.MULTILINE):
|
|
||||||
sys.stderr.write(f"sims-cli: open-dmenu failed:\n{out}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn, _ = server.accept()
|
|
||||||
except socket.timeout:
|
|
||||||
sys.stderr.write("sims-cli: dmenu timed out\n")
|
|
||||||
sys.exit(1)
|
|
||||||
with conn:
|
|
||||||
conn.settimeout(5)
|
|
||||||
chunks = []
|
|
||||||
try:
|
|
||||||
while chunk := conn.recv(4096):
|
|
||||||
chunks.append(chunk)
|
|
||||||
except OSError as e:
|
|
||||||
sys.stderr.write(f"sims-cli: dmenu reply failed: {e}\n")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
choice = b"".join(chunks).decode("utf-8").rstrip("\n")
|
|
||||||
if not choice:
|
|
||||||
sys.exit(1)
|
|
||||||
sys.stdout.write(choice + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="sims-cli",
|
prog="sims-cli",
|
||||||
@@ -214,16 +147,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
corners_sub.add_parser(sub_name, help=sub_help)
|
corners_sub.add_parser(sub_name, help=sub_help)
|
||||||
corners.set_defaults(func=_cmd_corners)
|
corners.set_defaults(func=_cmd_corners)
|
||||||
|
|
||||||
theme = sub.add_parser("theme", help="light/dark mode (via darkman)")
|
|
||||||
theme_sub = theme.add_subparsers(dest="theme_cmd", required=True, metavar="MODE")
|
|
||||||
for sub_name, sub_help in [
|
|
||||||
("toggle", "flip between light and dark mode"),
|
|
||||||
("light", "switch to light mode"),
|
|
||||||
("dark", "switch to dark mode"),
|
|
||||||
]:
|
|
||||||
theme_sub.add_parser(sub_name, help=sub_help)
|
|
||||||
theme.set_defaults(func=_cmd_theme)
|
|
||||||
|
|
||||||
lst = sub.add_parser("list", help="list registered actions")
|
lst = sub.add_parser("list", help="list registered actions")
|
||||||
lst.add_argument("--json", action="store_true", help="emit JSON array")
|
lst.add_argument("--json", action="store_true", help="emit JSON array")
|
||||||
lst.set_defaults(func=_cmd_list)
|
lst.set_defaults(func=_cmd_list)
|
||||||
@@ -235,19 +158,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
inv.add_argument("action", help="dbus action name, e.g. open-finder")
|
inv.add_argument("action", help="dbus action name, e.g. open-finder")
|
||||||
inv.set_defaults(func=_cmd_invoke)
|
inv.set_defaults(func=_cmd_invoke)
|
||||||
|
|
||||||
dm = sub.add_parser(
|
|
||||||
"dmenu",
|
|
||||||
help="dmenu-compatible picker (stdin lines -> chosen line on stdout)",
|
|
||||||
)
|
|
||||||
dm.add_argument("-p", "--prompt", default="Select...", help="placeholder text")
|
|
||||||
dm.add_argument(
|
|
||||||
"--timeout",
|
|
||||||
type=float,
|
|
||||||
default=300.0,
|
|
||||||
help="seconds to wait for a choice before giving up (default: 300)",
|
|
||||||
)
|
|
||||||
dm.set_defaults(func=_cmd_dmenu)
|
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ if app_config is None:
|
|||||||
raise Exception("Config file missing")
|
raise Exception("Config file missing")
|
||||||
|
|
||||||
VINYL = app_config.get("vinyl", {"enable": False})
|
VINYL = app_config.get("vinyl", {"enable": False})
|
||||||
DARKMAN = app_config.get("darkman", {"enable": False})
|
|
||||||
BATTERY = app_config.get("battery", {"enable": False})
|
BATTERY = app_config.get("battery", {"enable": False})
|
||||||
WINDOW_TITLE = app_config.get("window_title", {"enable": True})
|
WINDOW_TITLE = app_config.get("window_title", {"enable": True})
|
||||||
STYLIX = app_config.get("stylix", {"enable": False})
|
STYLIX = app_config.get("stylix", {"enable": False})
|
||||||
@@ -77,13 +76,3 @@ NOTIFICATIONS = app_config.get("notifications", {
|
|||||||
BAR_HEIGHT = app_config.get("height", 40)
|
BAR_HEIGHT = app_config.get("height", 40)
|
||||||
LOG_LEVEL = app_config.get("logLevel", "WARNING")
|
LOG_LEVEL = app_config.get("logLevel", "WARNING")
|
||||||
DEV = app_config.get("dev", False)
|
DEV = app_config.get("dev", False)
|
||||||
BUDDY = app_config.get("buddy", {"enable": False})
|
|
||||||
ORG = app_config.get("org", {"enable": False})
|
|
||||||
ORG.setdefault("paths", [])
|
|
||||||
ORG.setdefault("todo_keywords", ["TODO", "NEXT", "IN-PROGRESS"])
|
|
||||||
ORG.setdefault("done_keywords", ["DONE", "CANCELLED"])
|
|
||||||
ORG.setdefault("counted_states", ["TODO", "NEXT", "IN-PROGRESS"])
|
|
||||||
ORG.setdefault("update_interval", 60_000)
|
|
||||||
ORG.setdefault("emacsclient_command", "emacsclient")
|
|
||||||
ORG.setdefault("dropdown_width", 420)
|
|
||||||
ORG.setdefault("dropdown_height", 480)
|
|
||||||
|
|||||||
+14
-78
@@ -22,7 +22,6 @@ from .modules.bar import StatusBar
|
|||||||
from .modules.window_fuzzy import FuzzyWindowFinder
|
from .modules.window_fuzzy import FuzzyWindowFinder
|
||||||
from .modules.launcher.apps import AppLauncher
|
from .modules.launcher.apps import AppLauncher
|
||||||
from .modules.launcher.clipboard import ClipboardMenu
|
from .modules.launcher.clipboard import ClipboardMenu
|
||||||
from .modules.launcher.dmenu import DmenuMenu
|
|
||||||
from .modules.launcher.notmuch_search import NotmuchSearchMenu
|
from .modules.launcher.notmuch_search import NotmuchSearchMenu
|
||||||
from .modules.launcher.power import PowerMenu
|
from .modules.launcher.power import PowerMenu
|
||||||
from .modules.launcher.screenrec import ScreenrecMenu
|
from .modules.launcher.screenrec import ScreenrecMenu
|
||||||
@@ -30,29 +29,18 @@ from .modules.launcher.screenshot import ScreenshotMenu
|
|||||||
from .modules.calendar import CalendarService
|
from .modules.calendar import CalendarService
|
||||||
from .modules.control_center import ControlCenter
|
from .modules.control_center import ControlCenter
|
||||||
from .modules.notifications import NotificationToasts
|
from .modules.notifications import NotificationToasts
|
||||||
from .modules.org import OrgDropdown
|
from .modules.stylix import get_stylix_css_path
|
||||||
from .modules.stylix import generate_stylix_css
|
|
||||||
from .modules.theme_toggle import ThemeToggleButton
|
|
||||||
from .modules.vinyl import VinylButton
|
from .modules.vinyl import VinylButton
|
||||||
from .services.org import OrgService
|
from .config import CALENDAR, NOTIFICATIONS, POWER, SCREENREC, STYLIX, VINYL
|
||||||
from .config import CALENDAR, DARKMAN, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL
|
|
||||||
from .services.darkman import get_darkman_service
|
|
||||||
from .services.fenster import get_i3_connection
|
from .services.fenster import get_i3_connection
|
||||||
from .services.capture import get_capture_service
|
|
||||||
from .services.notification_history import NotificationHistoryService
|
from .services.notification_history import NotificationHistoryService
|
||||||
from .services.screenrec import ScreenrecService
|
from .services.screenrec import ScreenrecService
|
||||||
from .services.theme import get_theme_service
|
|
||||||
|
|
||||||
from gi.repository import GLib, Gtk
|
|
||||||
|
|
||||||
from fabric.notifications import Notifications
|
from fabric.notifications import Notifications
|
||||||
|
|
||||||
|
|
||||||
tray = SystemTray(name="system-tray", spacing=2, icon_size=16)
|
tray = SystemTray(name="system-tray", spacing=4)
|
||||||
get_i3_connection()
|
get_i3_connection()
|
||||||
# Subscribe before the main loop runs so fenster's replayed capture state
|
|
||||||
# (sent right after SUBSCRIBE) is not missed.
|
|
||||||
get_capture_service()
|
|
||||||
|
|
||||||
dummy = Window(visible=False)
|
dummy = Window(visible=False)
|
||||||
finder = FuzzyWindowFinder()
|
finder = FuzzyWindowFinder()
|
||||||
@@ -61,7 +49,6 @@ clipboard_menu = ClipboardMenu()
|
|||||||
power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"]))
|
power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"]))
|
||||||
screenshot_menu = ScreenshotMenu()
|
screenshot_menu = ScreenshotMenu()
|
||||||
notmuch_search_menu = NotmuchSearchMenu()
|
notmuch_search_menu = NotmuchSearchMenu()
|
||||||
dmenu_menu = DmenuMenu()
|
|
||||||
|
|
||||||
screenrec_service: ScreenrecService | None = None
|
screenrec_service: ScreenrecService | None = None
|
||||||
screenrec_menu = None
|
screenrec_menu = None
|
||||||
@@ -91,9 +78,6 @@ if NOTIFICATIONS.get("enable", False):
|
|||||||
)
|
)
|
||||||
|
|
||||||
vinyl_button: VinylButton | None = VinylButton() if VINYL.get("enable", False) else None
|
vinyl_button: VinylButton | None = VinylButton() if VINYL.get("enable", False) else None
|
||||||
theme_button: ThemeToggleButton | None = (
|
|
||||||
ThemeToggleButton(get_darkman_service()) if DARKMAN.get("enable", False) else None
|
|
||||||
)
|
|
||||||
calendar_service: CalendarService | None = (
|
calendar_service: CalendarService | None = (
|
||||||
CalendarService(update_interval=120000) if CALENDAR.get("enable", True) else None
|
CalendarService(update_interval=120000) if CALENDAR.get("enable", True) else None
|
||||||
)
|
)
|
||||||
@@ -104,31 +88,20 @@ if notification_history is not None:
|
|||||||
history=notification_history,
|
history=notification_history,
|
||||||
calendar_service=calendar_service,
|
calendar_service=calendar_service,
|
||||||
vinyl_button=vinyl_button,
|
vinyl_button=vinyl_button,
|
||||||
theme_button=theme_button,
|
|
||||||
monitor=0,
|
monitor=0,
|
||||||
width=NOTIFICATIONS.get("center_width", 380),
|
width=NOTIFICATIONS.get("center_width", 380),
|
||||||
)
|
)
|
||||||
|
|
||||||
org_service: OrgService | None = None
|
|
||||||
org_dropdown: OrgDropdown | None = None
|
|
||||||
if ORG.get("enable", False):
|
|
||||||
org_service = OrgService(
|
|
||||||
update_interval=int(ORG.get("update_interval", 60_000)),
|
|
||||||
)
|
|
||||||
org_dropdown = OrgDropdown(service=org_service, monitor=0)
|
|
||||||
|
|
||||||
bar_windows = []
|
bar_windows = []
|
||||||
notmuch_widget = None
|
notmuch_widget = None
|
||||||
|
|
||||||
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu, notmuch_search_menu, dmenu_menu]
|
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu, notmuch_search_menu]
|
||||||
if screenrec_menu is not None:
|
if screenrec_menu is not None:
|
||||||
_app_windows.append(screenrec_menu)
|
_app_windows.append(screenrec_menu)
|
||||||
if notification_toasts is not None:
|
if notification_toasts is not None:
|
||||||
_app_windows.append(notification_toasts)
|
_app_windows.append(notification_toasts)
|
||||||
if control_center is not None:
|
if control_center is not None:
|
||||||
_app_windows.append(control_center)
|
_app_windows.append(control_center)
|
||||||
if org_dropdown is not None:
|
|
||||||
_app_windows.append(org_dropdown)
|
|
||||||
app = Application("sims", *_app_windows)
|
app = Application("sims", *_app_windows)
|
||||||
|
|
||||||
|
|
||||||
@@ -157,12 +130,6 @@ def open_screenshot_menu():
|
|||||||
screenshot_menu.show()
|
screenshot_menu.show()
|
||||||
|
|
||||||
|
|
||||||
@Application.action()
|
|
||||||
def open_dmenu(request_dir: str):
|
|
||||||
"""Driven by `sims-cli dmenu`; see sims/modules/launcher/dmenu.py."""
|
|
||||||
dmenu_menu.open_request(request_dir)
|
|
||||||
|
|
||||||
|
|
||||||
@Application.action()
|
@Application.action()
|
||||||
def refresh_notmuch():
|
def refresh_notmuch():
|
||||||
if notmuch_widget is not None:
|
if notmuch_widget is not None:
|
||||||
@@ -204,24 +171,6 @@ def toggle_control_center():
|
|||||||
control_center.toggle()
|
control_center.toggle()
|
||||||
|
|
||||||
|
|
||||||
@Application.action()
|
|
||||||
def theme_toggle():
|
|
||||||
if DARKMAN.get("enable", False):
|
|
||||||
get_darkman_service().toggle()
|
|
||||||
|
|
||||||
|
|
||||||
@Application.action()
|
|
||||||
def theme_set_light():
|
|
||||||
if DARKMAN.get("enable", False):
|
|
||||||
get_darkman_service().set_mode("light")
|
|
||||||
|
|
||||||
|
|
||||||
@Application.action()
|
|
||||||
def theme_set_dark():
|
|
||||||
if DARKMAN.get("enable", False):
|
|
||||||
get_darkman_service().set_mode("dark")
|
|
||||||
|
|
||||||
|
|
||||||
def _set_all_bars_rounded(rounded: bool):
|
def _set_all_bars_rounded(rounded: bool):
|
||||||
for bar in bar_windows:
|
for bar in bar_windows:
|
||||||
bar.set_corners_rounded(rounded)
|
bar.set_corners_rounded(rounded)
|
||||||
@@ -243,29 +192,18 @@ def toggle_bar_corners():
|
|||||||
_set_all_bars_rounded(new_state)
|
_set_all_bars_rounded(new_state)
|
||||||
|
|
||||||
# Load CSS - use Stylix if enabled, otherwise use default
|
# Load CSS - use Stylix if enabled, otherwise use default
|
||||||
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
|
||||||
|
|
||||||
if STYLIX.get("enable", False):
|
if STYLIX.get("enable", False):
|
||||||
# Own the stylix provider so a light/dark switch can reload its data in
|
stylix_css_path = get_stylix_css_path()
|
||||||
# place; GTK restyles every widget when a provider changes.
|
if stylix_css_path:
|
||||||
stylix_provider = Gtk.CssProvider()
|
logger.info("[Bar] Using Stylix CSS")
|
||||||
theme_service = get_theme_service()
|
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
||||||
|
app.set_stylesheet_from_file(stylix_css_path, append=True)
|
||||||
def _load_stylix_css(mode=None):
|
else:
|
||||||
css = generate_stylix_css(mode)
|
logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default")
|
||||||
if css is None:
|
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
||||||
return
|
|
||||||
try:
|
|
||||||
stylix_provider.load_from_data(css.encode())
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"[Bar] Failed to load Stylix CSS: {e.message}")
|
|
||||||
|
|
||||||
_load_stylix_css(theme_service.mode)
|
|
||||||
app.add_style_provider(stylix_provider)
|
|
||||||
theme_service.connect("changed", lambda _svc, mode: _load_stylix_css(mode))
|
|
||||||
logger.info("[Bar] Using Stylix CSS")
|
|
||||||
else:
|
else:
|
||||||
logger.info("[Bar] Using default CSS")
|
logger.info("[Bar] Using default CSS")
|
||||||
|
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
||||||
|
|
||||||
|
|
||||||
def spawn_bars():
|
def spawn_bars():
|
||||||
@@ -289,10 +227,8 @@ def spawn_bars():
|
|||||||
display=output_name,
|
display=output_name,
|
||||||
tray=tray if i == 0 else None,
|
tray=tray if i == 0 else None,
|
||||||
monitor=i,
|
monitor=i,
|
||||||
screenrec_service=screenrec_service,
|
screenrec_service=screenrec_service if i == 0 else None,
|
||||||
control_center=control_center if i == 0 else None,
|
control_center=control_center if i == 0 else None,
|
||||||
org_service=org_service,
|
|
||||||
org_dropdown=org_dropdown if i == 0 else None,
|
|
||||||
)
|
)
|
||||||
bar_windows.append(bar)
|
bar_windows.append(bar)
|
||||||
if i == 0 and bar.notmuch:
|
if i == 0 and bar.notmuch:
|
||||||
|
|||||||
+38
-86
@@ -1,29 +1,24 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from fabric.widgets.box import Box
|
from fabric.widgets.box import Box
|
||||||
from fabric.widgets.label import Label
|
from fabric.widgets.label import Label
|
||||||
|
from fabric.widgets.image import Image
|
||||||
from fabric.widgets.overlay import Overlay
|
from fabric.widgets.overlay import Overlay
|
||||||
|
from fabric.widgets.datetime import DateTime
|
||||||
from fabric.widgets.centerbox import CenterBox
|
from fabric.widgets.centerbox import CenterBox
|
||||||
from fabric.utils import invoke_repeater
|
|
||||||
from sims.modules.player import Player, PlayerSmall
|
from sims.modules.player import Player, PlayerSmall
|
||||||
from sims.modules.battery import Battery
|
from sims.modules.battery import Battery
|
||||||
from sims.modules.control_center import ControlCenter
|
from sims.modules.control_center import ControlCenter
|
||||||
from sims.modules.notmuch import NotmuchWidget
|
from sims.modules.notmuch import NotmuchWidget
|
||||||
from sims.modules.buddy import Buddy
|
from sims.modules.screenrec import ScreenrecWidget
|
||||||
from sims.modules.org import OrgDropdown, OrgWidget
|
|
||||||
from sims.modules.capture import CaptureWidget
|
|
||||||
from sims.modules.microphone import MicrophoneWidget
|
|
||||||
from fabric.widgets.wayland import WaylandWindow as Window
|
from fabric.widgets.wayland import WaylandWindow as Window
|
||||||
from fabric.system_tray.widgets import SystemTray
|
from fabric.system_tray.widgets import SystemTray
|
||||||
from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
|
from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
|
||||||
from sims.services.org import OrgService
|
|
||||||
from sims.services.screenrec import ScreenrecService
|
from sims.services.screenrec import ScreenrecService
|
||||||
from sims.services.smart_corners import get_smart_corners_service
|
from sims.services.smart_corners import get_smart_corners_service
|
||||||
from fabric.widgets.button import Button
|
from fabric.widgets.button import Button
|
||||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||||
from sims.services.system_stats import get_system_stats_service
|
from sims.services.system_stats import SystemStatsService
|
||||||
|
|
||||||
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH, BUDDY, ORG
|
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
|
||||||
|
|
||||||
|
|
||||||
class StatusBar(Window):
|
class StatusBar(Window):
|
||||||
@@ -34,8 +29,6 @@ class StatusBar(Window):
|
|||||||
monitor: int = 1,
|
monitor: int = 1,
|
||||||
screenrec_service: ScreenrecService | None = None,
|
screenrec_service: ScreenrecService | None = None,
|
||||||
control_center: ControlCenter | None = None,
|
control_center: ControlCenter | None = None,
|
||||||
org_service: OrgService | None = None,
|
|
||||||
org_dropdown: OrgDropdown | None = None,
|
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name="sims",
|
name="sims",
|
||||||
@@ -57,10 +50,12 @@ class StatusBar(Window):
|
|||||||
spacing=4,
|
spacing=4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M")
|
||||||
self.date_time = Button(
|
self.date_time = Button(
|
||||||
name="date-time-button",
|
name="date-time-button",
|
||||||
child=Clock(),
|
child=datetime_widget,
|
||||||
on_clicked=self._on_date_time_clicked,
|
on_clicked=self._on_date_time_clicked,
|
||||||
|
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
|
||||||
)
|
)
|
||||||
self.control_center = control_center
|
self.control_center = control_center
|
||||||
|
|
||||||
@@ -69,24 +64,25 @@ class StatusBar(Window):
|
|||||||
self.active_window = FensterActiveWindow(
|
self.active_window = FensterActiveWindow(
|
||||||
name="active-window",
|
name="active-window",
|
||||||
max_length=50,
|
max_length=50,
|
||||||
|
style="color: #ffffff; font-size: 14px; font-weight: bold;",
|
||||||
)
|
)
|
||||||
|
|
||||||
# CPU/RAM as concentric "activity rings": outer = RAM, inner = CPU.
|
|
||||||
ring = dict(line_width=3, start_angle=-90, end_angle=270,
|
|
||||||
h_align="center", v_align="center")
|
|
||||||
self.ram_progress_bar = CircularProgressBar(
|
self.ram_progress_bar = CircularProgressBar(
|
||||||
name="ram-progress-bar", size=18, **ring
|
name="ram-progress-bar", pie=True, size=24
|
||||||
)
|
)
|
||||||
self.cpu_progress_bar = CircularProgressBar(
|
self.cpu_progress_bar = CircularProgressBar(
|
||||||
name="cpu-progress-bar", size=8, **ring
|
name="cpu-progress-bar", pie=True, size=24
|
||||||
|
)
|
||||||
|
|
||||||
|
self.progress_label = Label(
|
||||||
|
"", style="margin: 0px 6px 0px 0px; font-size: 12px"
|
||||||
)
|
)
|
||||||
self.progress_bars_overlay = Overlay(
|
self.progress_bars_overlay = Overlay(
|
||||||
name="system-stats",
|
|
||||||
child=self.ram_progress_bar,
|
child=self.ram_progress_bar,
|
||||||
overlays=[self.cpu_progress_bar],
|
overlays=[self.cpu_progress_bar, self.progress_label],
|
||||||
)
|
)
|
||||||
self.player = Player()
|
self.player = Player()
|
||||||
self.player_small = PlayerSmall(monitor=monitor)
|
self.player_small = PlayerSmall()
|
||||||
|
|
||||||
self.battery = None
|
self.battery = None
|
||||||
if BATTERY["enable"]:
|
if BATTERY["enable"]:
|
||||||
@@ -96,43 +92,31 @@ class StatusBar(Window):
|
|||||||
if NOTMUCH["enable"]:
|
if NOTMUCH["enable"]:
|
||||||
self.notmuch = NotmuchWidget()
|
self.notmuch = NotmuchWidget()
|
||||||
|
|
||||||
self.buddy = None
|
self.screenrec = None
|
||||||
if BUDDY.get("enable", False):
|
if screenrec_service is not None:
|
||||||
notmuch_service = self.notmuch.service if self.notmuch is not None else None
|
self.screenrec = ScreenrecWidget(screenrec_service)
|
||||||
self.buddy = Buddy(notmuch_service=notmuch_service)
|
|
||||||
|
|
||||||
self.capture = CaptureWidget(screenrec_service=screenrec_service)
|
|
||||||
|
|
||||||
self.org = None
|
|
||||||
if org_service is not None:
|
|
||||||
self.org = OrgWidget(service=org_service, dropdown=org_dropdown)
|
|
||||||
|
|
||||||
self.microphone = MicrophoneWidget()
|
|
||||||
|
|
||||||
# One chip for the ambient system state; attention items (org,
|
|
||||||
# mail, screen share) stay separate so they can light up.
|
|
||||||
status_children = [self.microphone, self.progress_bars_overlay]
|
|
||||||
if self.system_tray:
|
|
||||||
status_children.append(self.system_tray)
|
|
||||||
if self.battery:
|
|
||||||
status_children.append(self.battery)
|
|
||||||
self.status_container = Box(
|
self.status_container = Box(
|
||||||
name="widgets-container",
|
name="widgets-container",
|
||||||
spacing=6,
|
spacing=4,
|
||||||
orientation="h",
|
orientation="h",
|
||||||
children=status_children,
|
children=self.progress_bars_overlay,
|
||||||
)
|
)
|
||||||
|
|
||||||
end_container_children = []
|
end_container_children = []
|
||||||
|
|
||||||
if self.org is not None:
|
end_container_children.append(self.status_container)
|
||||||
end_container_children.append(self.org)
|
if self.system_tray:
|
||||||
|
end_container_children.append(self.system_tray)
|
||||||
|
|
||||||
|
if self.battery:
|
||||||
|
end_container_children.append(self.battery)
|
||||||
|
|
||||||
if self.notmuch:
|
if self.notmuch:
|
||||||
end_container_children.append(self.notmuch)
|
end_container_children.append(self.notmuch)
|
||||||
|
|
||||||
end_container_children.append(self.capture)
|
if self.screenrec:
|
||||||
end_container_children.append(self.status_container)
|
end_container_children.append(self.screenrec)
|
||||||
|
|
||||||
end_container_children.append(self.date_time)
|
end_container_children.append(self.date_time)
|
||||||
|
|
||||||
@@ -140,24 +124,17 @@ class StatusBar(Window):
|
|||||||
if WINDOW_TITLE["enable"]:
|
if WINDOW_TITLE["enable"]:
|
||||||
center_children.append(self.active_window)
|
center_children.append(self.active_window)
|
||||||
|
|
||||||
start_container_children = [
|
|
||||||
# Nerd Font glyph (nf-linux-nixos) rather than the nix-snowflake-white
|
|
||||||
# icon: that SVG is hard-coded white and vanishes on light themes,
|
|
||||||
# while a glyph follows the CSS `color`.
|
|
||||||
Label(name="nixos-label", label="\uf313"),
|
|
||||||
self.workspaces,
|
|
||||||
self.player_small,
|
|
||||||
]
|
|
||||||
if self.buddy is not None:
|
|
||||||
start_container_children.append(self.buddy)
|
|
||||||
|
|
||||||
self.inner = CenterBox(
|
self.inner = CenterBox(
|
||||||
name="sims-inner",
|
name="sims-inner",
|
||||||
start_children=Box(
|
start_children=Box(
|
||||||
name="start-container",
|
name="start-container",
|
||||||
spacing=8,
|
spacing=6,
|
||||||
orientation="h",
|
orientation="h",
|
||||||
children=start_container_children,
|
children=[
|
||||||
|
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
|
||||||
|
self.workspaces,
|
||||||
|
self.player_small,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
center_children=Box(
|
center_children=Box(
|
||||||
name="center-container",
|
name="center-container",
|
||||||
@@ -167,14 +144,14 @@ class StatusBar(Window):
|
|||||||
),
|
),
|
||||||
end_children=Box(
|
end_children=Box(
|
||||||
name="end-container",
|
name="end-container",
|
||||||
spacing=6,
|
spacing=4,
|
||||||
orientation="h",
|
orientation="h",
|
||||||
children=end_container_children,
|
children=end_container_children,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.children = self.inner
|
self.children = self.inner
|
||||||
|
|
||||||
self.system_stats_service = get_system_stats_service()
|
self.system_stats_service = SystemStatsService(update_interval=3000)
|
||||||
self.system_stats_service.connect("stats-changed", self.update_progress_bars)
|
self.system_stats_service.connect("stats-changed", self.update_progress_bars)
|
||||||
|
|
||||||
self.set_size_request(-1, BAR_HEIGHT)
|
self.set_size_request(-1, BAR_HEIGHT)
|
||||||
@@ -218,32 +195,7 @@ class StatusBar(Window):
|
|||||||
def update_progress_bars(self, service, cpu_percent, memory_percent):
|
def update_progress_bars(self, service, cpu_percent, memory_percent):
|
||||||
self.cpu_progress_bar.value = cpu_percent
|
self.cpu_progress_bar.value = cpu_percent
|
||||||
self.ram_progress_bar.value = memory_percent
|
self.ram_progress_bar.value = memory_percent
|
||||||
self.progress_bars_overlay.set_tooltip_text(
|
|
||||||
f"CPU {cpu_percent * 100:.0f}% · RAM {memory_percent * 100:.0f}%"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_date_time_clicked(self, _button=None):
|
def _on_date_time_clicked(self, _button=None):
|
||||||
if self.control_center is not None:
|
if self.control_center is not None:
|
||||||
self.control_center.toggle()
|
self.control_center.toggle()
|
||||||
|
|
||||||
|
|
||||||
class Clock(Box):
|
|
||||||
"""Bold time + muted date, e.g. `20:26 Mon 22 Jul`."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self._time = Label(name="clock-time")
|
|
||||||
self._date = Label(name="clock-date")
|
|
||||||
super().__init__(
|
|
||||||
name="date-time",
|
|
||||||
orientation="h",
|
|
||||||
spacing=8,
|
|
||||||
children=[self._time, self._date],
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
invoke_repeater(1000, self._update)
|
|
||||||
|
|
||||||
def _update(self) -> bool:
|
|
||||||
now = time.localtime()
|
|
||||||
self._time.set_label(time.strftime("%H:%M", now))
|
|
||||||
self._date.set_label(time.strftime("%a %d %b", now))
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from gi.repository import GLib
|
|||||||
from fabric.widgets.box import Box
|
from fabric.widgets.box import Box
|
||||||
from fabric.widgets.label import Label
|
from fabric.widgets.label import Label
|
||||||
from fabric.widgets.image import Image
|
from fabric.widgets.image import Image
|
||||||
from sims.services.battery import get_battery_service
|
from sims.services.battery import BatteryService
|
||||||
|
|
||||||
|
|
||||||
class Battery(Box):
|
class Battery(Box):
|
||||||
@@ -15,9 +15,8 @@ class Battery(Box):
|
|||||||
|
|
||||||
self.bat_label = Label(name="bat-label", label="100%")
|
self.bat_label = Label(name="bat-label", label="100%")
|
||||||
|
|
||||||
# Shared battery service with signal-based updates (one poller for the
|
# Create battery service with signal-based updates
|
||||||
# whole process, including the buddy's battery checks)
|
self.battery_service = BatteryService(update_interval=10000) # Check every 10 seconds
|
||||||
self.battery_service = get_battery_service()
|
|
||||||
self.battery_service.connect("battery-changed", self.update_battery)
|
self.battery_service.connect("battery-changed", self.update_battery)
|
||||||
|
|
||||||
self.children = [self.bat_icon, self.bat_label]
|
self.children = [self.bat_icon, self.bat_label]
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
from fabric.widgets.box import Box
|
|
||||||
|
|
||||||
from sims.widgets.buddy import BuddyWidget
|
|
||||||
from sims.services.buddy import get_buddy_service
|
|
||||||
|
|
||||||
|
|
||||||
class Buddy(Box):
|
|
||||||
def __init__(self, notmuch_service=None, **kwargs):
|
|
||||||
super().__init__(name="buddy-container", v_align="center", **kwargs)
|
|
||||||
self.service = get_buddy_service(notmuch_service=notmuch_service)
|
|
||||||
self.sprite = BuddyWidget(on_pet=self._on_pet)
|
|
||||||
self.service.connect("mood-changed", self._on_mood_changed)
|
|
||||||
self.children = [self.sprite]
|
|
||||||
# The shared service only emits on *changes* and may already sit in a
|
|
||||||
# non-idle mood when this output's sprite is built: adopt it now.
|
|
||||||
self.sprite.update_mood(self.service.mood)
|
|
||||||
|
|
||||||
def _on_mood_changed(self, _service, mood: str):
|
|
||||||
self.sprite.update_mood(mood)
|
|
||||||
|
|
||||||
def _on_pet(self, **kwargs):
|
|
||||||
if kwargs.get("petted"):
|
|
||||||
self.service.pet()
|
|
||||||
+17
-27
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
@@ -130,19 +131,18 @@ class CalendarService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# khal 0.11.3 does not support the newer `--json` output flags.
|
|
||||||
# Use the documented `list --format ... --day-format ... START DELTA`
|
|
||||||
# form instead and parse one event per line. `{bell}` is an uncommon
|
|
||||||
# control-code delimiter supported by khal's formatter.
|
|
||||||
cmd = [
|
cmd = [
|
||||||
khal_path,
|
khal_path,
|
||||||
"list",
|
"list",
|
||||||
"--format",
|
"--json",
|
||||||
"{start-date}{bell}{start-time}{bell}{end-date}{bell}{end-time}{bell}{title}{bell}{location}",
|
"title",
|
||||||
"--day-format",
|
"--json",
|
||||||
"",
|
"start",
|
||||||
|
"--json",
|
||||||
|
"end",
|
||||||
|
"--json",
|
||||||
|
"location",
|
||||||
"today",
|
"today",
|
||||||
"eod",
|
|
||||||
]
|
]
|
||||||
logger.info(f"[Calendar] Running command: {' '.join(cmd)}")
|
logger.info(f"[Calendar] Running command: {' '.join(cmd)}")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -155,26 +155,16 @@ class CalendarService:
|
|||||||
logger.info(f"[Calendar] Command stderr: {result.stderr[:200]}...")
|
logger.info(f"[Calendar] Command stderr: {result.stderr[:200]}...")
|
||||||
|
|
||||||
if result.stdout.strip():
|
if result.stdout.strip():
|
||||||
|
lines = result.stdout.strip().split("\n")
|
||||||
all_events = []
|
all_events = []
|
||||||
|
|
||||||
for line in result.stdout.splitlines():
|
for line in lines:
|
||||||
if not line.strip():
|
if line.strip():
|
||||||
continue
|
try:
|
||||||
|
events = json.loads(line)
|
||||||
parts = line.split("\a", 5)
|
all_events.extend(events)
|
||||||
if len(parts) != 6:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"[Calendar] Could not parse khal output line: {line}")
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
start_date, start_time, end_date, end_time, title, location = parts
|
|
||||||
all_events.append(
|
|
||||||
{
|
|
||||||
"title": title.strip() or "No title",
|
|
||||||
"start": f"{start_date} {start_time}".strip() if start_time else "",
|
|
||||||
"end": f"{end_date} {end_time}".strip() if end_time else "",
|
|
||||||
"location": location.strip(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.events = all_events
|
self.events = all_events
|
||||||
logger.info(f"[Calendar] Found {len(self.events)} events using subprocess")
|
logger.info(f"[Calendar] Found {len(self.events)} events using subprocess")
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Screen-share indicator.
|
|
||||||
|
|
||||||
Minimal by default: a single red dot, visible only while fenster reports an
|
|
||||||
active screen capture (portal screen shares, wl-screenrec, ...). Hovering
|
|
||||||
slides out a short summary of what is being captured; the tooltip lists every
|
|
||||||
captured output/window. Clicking stops a running sims screen recording.
|
|
||||||
"""
|
|
||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.button import Button
|
|
||||||
from fabric.widgets.label import Label
|
|
||||||
from fabric.widgets.revealer import Revealer
|
|
||||||
|
|
||||||
from sims.services.capture import CaptureService, get_capture_service
|
|
||||||
from sims.services.screenrec import ScreenrecService
|
|
||||||
|
|
||||||
SUMMARY_MAX_CHARS = 40
|
|
||||||
|
|
||||||
|
|
||||||
def _window_label(window: dict) -> str:
|
|
||||||
return window.get("app_id") or window.get("title") or f"window {window.get('id')}"
|
|
||||||
|
|
||||||
|
|
||||||
def _sessions_suffix(entry: dict) -> str:
|
|
||||||
sessions = entry.get("sessions", 1)
|
|
||||||
return f" ({sessions}×)" if isinstance(sessions, int) and sessions > 1 else ""
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureWidget(Button):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
service: CaptureService | None = None,
|
|
||||||
screenrec_service: ScreenrecService | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
self._service = service or get_capture_service()
|
|
||||||
self._screenrec = screenrec_service
|
|
||||||
|
|
||||||
self._dot = Label(name="capture-dot", label="●")
|
|
||||||
self._summary = Label(name="capture-summary", label="")
|
|
||||||
self._revealer = Revealer(
|
|
||||||
child=self._summary,
|
|
||||||
transition_type="slide-left",
|
|
||||||
transition_duration=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
name="capture-widget",
|
|
||||||
child=Box(orientation="h", children=[self._revealer, self._dot]),
|
|
||||||
on_clicked=self._on_clicked,
|
|
||||||
visible=False,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self.set_no_show_all(True)
|
|
||||||
|
|
||||||
self.connect("enter-notify-event", lambda *_: self._revealer.reveal())
|
|
||||||
self.connect("leave-notify-event", lambda *_: self._revealer.unreveal())
|
|
||||||
|
|
||||||
self._service.connect("capture-changed", self._update)
|
|
||||||
if self._screenrec is not None:
|
|
||||||
self._screenrec.connect("recording-changed", self._update)
|
|
||||||
self._update()
|
|
||||||
|
|
||||||
def _on_clicked(self, *_):
|
|
||||||
if self._screenrec is not None and self._screenrec.recording:
|
|
||||||
self._screenrec.stop()
|
|
||||||
|
|
||||||
def _update(self, *_):
|
|
||||||
outputs = self._service.outputs
|
|
||||||
windows = self._service.windows
|
|
||||||
|
|
||||||
if not (outputs or windows):
|
|
||||||
self._revealer.unreveal()
|
|
||||||
self.set_visible(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
names = [o.get("name", "?") for o in outputs] + [_window_label(w) for w in windows]
|
|
||||||
summary = "Sharing " + ", ".join(names)
|
|
||||||
if len(summary) > SUMMARY_MAX_CHARS:
|
|
||||||
summary = summary[: SUMMARY_MAX_CHARS - 1] + "…"
|
|
||||||
self._summary.set_label(summary)
|
|
||||||
|
|
||||||
lines = [f"Monitor {o.get('name', '?')}{_sessions_suffix(o)}" for o in outputs]
|
|
||||||
for w in windows:
|
|
||||||
title = w.get("title")
|
|
||||||
label = _window_label(w)
|
|
||||||
detail = f"{label} — {title}" if title and title != label else label
|
|
||||||
lines.append(f"Window {detail}{_sessions_suffix(w)}")
|
|
||||||
if self._screenrec is not None and self._screenrec.recording:
|
|
||||||
lines.append("Click to stop recording")
|
|
||||||
self.set_tooltip_text("\n".join(lines))
|
|
||||||
|
|
||||||
self.set_visible(True)
|
|
||||||
@@ -9,7 +9,6 @@ from fabric.widgets.wayland import WaylandWindow as Window
|
|||||||
from gi.repository import Gdk
|
from gi.repository import Gdk
|
||||||
|
|
||||||
from sims.modules.calendar import CalendarService
|
from sims.modules.calendar import CalendarService
|
||||||
from sims.modules.theme_toggle import ThemeToggleButton
|
|
||||||
from sims.modules.vinyl import VinylButton
|
from sims.modules.vinyl import VinylButton
|
||||||
from sims.services.notification_history import NotificationHistoryService
|
from sims.services.notification_history import NotificationHistoryService
|
||||||
from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget
|
from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget
|
||||||
@@ -21,7 +20,6 @@ class ControlCenter(Window):
|
|||||||
history: NotificationHistoryService,
|
history: NotificationHistoryService,
|
||||||
calendar_service: CalendarService | None = None,
|
calendar_service: CalendarService | None = None,
|
||||||
vinyl_button: VinylButton | None = None,
|
vinyl_button: VinylButton | None = None,
|
||||||
theme_button: ThemeToggleButton | None = None,
|
|
||||||
monitor: int = 0,
|
monitor: int = 0,
|
||||||
width: int = 380,
|
width: int = 380,
|
||||||
):
|
):
|
||||||
@@ -37,7 +35,6 @@ class ControlCenter(Window):
|
|||||||
self._history = history
|
self._history = history
|
||||||
self._calendar_service = calendar_service
|
self._calendar_service = calendar_service
|
||||||
self._vinyl_button = vinyl_button
|
self._vinyl_button = vinyl_button
|
||||||
self._theme_button = theme_button
|
|
||||||
self._width = width
|
self._width = width
|
||||||
self._visibility_listeners: list = []
|
self._visibility_listeners: list = []
|
||||||
|
|
||||||
@@ -61,16 +58,8 @@ class ControlCenter(Window):
|
|||||||
|
|
||||||
sections: list = []
|
sections: list = []
|
||||||
|
|
||||||
settings_rows = [
|
if vinyl_button is not None:
|
||||||
(label, widget)
|
sections.append(self._build_settings_section())
|
||||||
for label, widget in (
|
|
||||||
("Dark Mode", theme_button),
|
|
||||||
("Vinyl Passthrough", vinyl_button),
|
|
||||||
)
|
|
||||||
if widget is not None
|
|
||||||
]
|
|
||||||
if settings_rows:
|
|
||||||
sections.append(self._build_settings_section(settings_rows))
|
|
||||||
|
|
||||||
if calendar_service is not None:
|
if calendar_service is not None:
|
||||||
sections.append(self._build_calendar_section())
|
sections.append(self._build_calendar_section())
|
||||||
@@ -134,26 +123,26 @@ class ControlCenter(Window):
|
|||||||
)
|
)
|
||||||
return section
|
return section
|
||||||
|
|
||||||
def _build_settings_section(self, rows: list) -> Box:
|
def _build_settings_section(self) -> Box:
|
||||||
section = self._build_section("control-center-settings", "Settings")
|
section = self._build_section("control-center-settings", "Settings")
|
||||||
for label, widget in rows:
|
|
||||||
row = Box(
|
row = Box(
|
||||||
name="control-center-settings-row",
|
name="control-center-settings-row",
|
||||||
orientation="h",
|
orientation="h",
|
||||||
spacing=8,
|
spacing=8,
|
||||||
)
|
)
|
||||||
row.pack_start(
|
row.pack_start(
|
||||||
Label(
|
Label(
|
||||||
name="control-center-settings-label",
|
name="control-center-settings-label",
|
||||||
label=label,
|
label="Vinyl Passthrough",
|
||||||
h_align="start",
|
h_align="start",
|
||||||
),
|
),
|
||||||
True,
|
True,
|
||||||
True,
|
True,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
row.pack_end(widget, False, False, 0)
|
row.pack_end(self._vinyl_button, False, False, 0)
|
||||||
section.add(row)
|
section.add(row)
|
||||||
return section
|
return section
|
||||||
|
|
||||||
def _build_calendar_section(self) -> Box:
|
def _build_calendar_section(self) -> Box:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
from .base import FuzzyMenu, LauncherProvider, StaticAction, StaticActionProvider
|
from .base import FuzzyMenu, LauncherProvider, StaticAction, StaticActionProvider
|
||||||
from .dmenu import DmenuMenu
|
|
||||||
from .windows import WindowProvider
|
from .windows import WindowProvider
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DmenuMenu",
|
|
||||||
"FuzzyMenu",
|
"FuzzyMenu",
|
||||||
"LauncherProvider",
|
"LauncherProvider",
|
||||||
"StaticAction",
|
"StaticAction",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
|
|
||||||
from fabric.utils.helpers import DesktopApp, get_desktop_applications
|
from fabric.utils.helpers import DesktopApp, get_desktop_applications
|
||||||
from fabric.widgets.box import Box
|
from fabric.widgets.box import Box
|
||||||
@@ -10,8 +9,6 @@ from fabric.widgets.label import Label
|
|||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
|
||||||
from .base import FuzzyMenu
|
from .base import FuzzyMenu
|
||||||
from .history import LaunchHistory
|
|
||||||
from .ranking import rank_items
|
|
||||||
|
|
||||||
|
|
||||||
ICON_SIZE = 32
|
ICON_SIZE = 32
|
||||||
@@ -20,27 +17,14 @@ _FIELD_CODE_RE = re.compile(r"^%[fFuUickdDnNvm]$")
|
|||||||
|
|
||||||
|
|
||||||
class AppProvider:
|
class AppProvider:
|
||||||
def __init__(self, history: LaunchHistory | None = None):
|
|
||||||
self._history = history if history is not None else LaunchHistory()
|
|
||||||
|
|
||||||
def items(self) -> list[DesktopApp]:
|
def items(self) -> list[DesktopApp]:
|
||||||
return get_desktop_applications()
|
return get_desktop_applications()
|
||||||
|
|
||||||
def filter(self, items: list[DesktopApp], query: str) -> list[DesktopApp]:
|
def filter(self, items: list[DesktopApp], query: str) -> list[DesktopApp]:
|
||||||
"""Rank apps against *query*, best match first.
|
if not query:
|
||||||
|
return items
|
||||||
With an empty query everything matches equally, so the order is
|
q = query.lower()
|
||||||
purely "what gets launched most" - the launcher opens on your
|
return [a for a in items if _matches(a, q)]
|
||||||
most useful apps instead of an arbitrary alphabetical slice.
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
return rank_items(
|
|
||||||
items,
|
|
||||||
query,
|
|
||||||
_fields,
|
|
||||||
bonus_of=lambda app: self._history.bonus(_app_key(app), now=now),
|
|
||||||
tie_break_of=_sort_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, item: DesktopApp) -> Gtk.Widget:
|
def render(self, item: DesktopApp) -> Gtk.Widget:
|
||||||
children: list[Gtk.Widget] = []
|
children: list[Gtk.Widget] = []
|
||||||
@@ -61,7 +45,6 @@ class AppProvider:
|
|||||||
return Box(name="slot-box", orientation="h", spacing=10, children=children)
|
return Box(name="slot-box", orientation="h", spacing=10, children=children)
|
||||||
|
|
||||||
def activate(self, item: DesktopApp) -> None:
|
def activate(self, item: DesktopApp) -> None:
|
||||||
self._history.record(_app_key(item))
|
|
||||||
# Launch in a transient systemd --user scope so the app gets its own
|
# Launch in a transient systemd --user scope so the app gets its own
|
||||||
# cgroup instead of inheriting sims.service's. start_new_session alone
|
# cgroup instead of inheriting sims.service's. start_new_session alone
|
||||||
# only changes POSIX session/pgid; systemd tracks units by cgroup and
|
# only changes POSIX session/pgid; systemd tracks units by cgroup and
|
||||||
@@ -87,36 +70,11 @@ class AppProvider:
|
|||||||
item.launch()
|
item.launch()
|
||||||
|
|
||||||
|
|
||||||
def _fields(app: DesktopApp) -> dict[str, str | None]:
|
def _matches(app: DesktopApp, q: str) -> bool:
|
||||||
return {
|
for field in (app.name, app.display_name, app.generic_name, app.executable):
|
||||||
"name": app.name,
|
if field and q in field.lower():
|
||||||
"display_name": app.display_name,
|
return True
|
||||||
"generic_name": app.generic_name,
|
return False
|
||||||
"executable": app.executable,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sort_name(app: DesktopApp) -> str:
|
|
||||||
return (app.display_name or app.name or "").casefold()
|
|
||||||
|
|
||||||
|
|
||||||
def _app_key(app: DesktopApp) -> str:
|
|
||||||
"""Stable identity for the launch history.
|
|
||||||
|
|
||||||
DesktopApp does not expose the desktop file id, but it wraps the
|
|
||||||
GioUnix.DesktopAppInfo which does. Fall back to the command line
|
|
||||||
(unique per Steam game) and finally the name.
|
|
||||||
"""
|
|
||||||
info = getattr(app, "_app", None)
|
|
||||||
get_id = getattr(info, "get_id", None) if info is not None else None
|
|
||||||
if callable(get_id):
|
|
||||||
try:
|
|
||||||
app_id = get_id()
|
|
||||||
except Exception: # pragma: no cover - defensive
|
|
||||||
app_id = None
|
|
||||||
if isinstance(app_id, str) and app_id:
|
|
||||||
return app_id
|
|
||||||
return app.command_line or app.executable or app.name or ""
|
|
||||||
|
|
||||||
|
|
||||||
def AppLauncher(monitor: int = 0) -> FuzzyMenu:
|
def AppLauncher(monitor: int = 0) -> FuzzyMenu:
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from gi.repository import Gdk, Gtk
|
|||||||
|
|
||||||
from sims.services.fenster import focused_output_index
|
from sims.services.fenster import focused_output_index
|
||||||
|
|
||||||
from .ranking import rank_items
|
|
||||||
|
|
||||||
|
|
||||||
class LauncherProvider(Protocol):
|
class LauncherProvider(Protocol):
|
||||||
def items(self) -> list[Any]: ...
|
def items(self) -> list[Any]: ...
|
||||||
@@ -49,15 +47,10 @@ class StaticActionProvider:
|
|||||||
return list(self._static or [])
|
return list(self._static or [])
|
||||||
|
|
||||||
def filter(self, items: list[StaticAction], query: str) -> list[StaticAction]:
|
def filter(self, items: list[StaticAction], query: str) -> list[StaticAction]:
|
||||||
# Fixed menus (screenshot, power, screenrec) have no history: an
|
if not query:
|
||||||
# empty query keeps the declaration order, a real query is ranked
|
return items
|
||||||
# like everything else in the launcher.
|
q = query.lower()
|
||||||
return rank_items(
|
return [i for i in items if q in i.label.lower()]
|
||||||
items,
|
|
||||||
query,
|
|
||||||
lambda action: {"label": action.label},
|
|
||||||
weights={"label": 1.0},
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, item: StaticAction) -> Gtk.Widget:
|
def render(self, item: StaticAction) -> Gtk.Widget:
|
||||||
return Box(
|
return Box(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from fabric.widgets.label import Label
|
|||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
|
||||||
from .base import FuzzyMenu
|
from .base import FuzzyMenu
|
||||||
from .ranking import rank_items
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -35,14 +34,10 @@ class ClipboardProvider:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
def filter(self, items: list[ClipEntry], query: str) -> list[ClipEntry]:
|
def filter(self, items: list[ClipEntry], query: str) -> list[ClipEntry]:
|
||||||
# Empty query keeps cliphist's newest-first order; a query is
|
if not query:
|
||||||
# ranked like every other launcher menu.
|
return items
|
||||||
return rank_items(
|
q = query.lower()
|
||||||
items,
|
return [e for e in items if q in e.preview.lower()]
|
||||||
query,
|
|
||||||
lambda entry: {"preview": entry.preview},
|
|
||||||
weights={"preview": 1.0},
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, item: ClipEntry) -> Gtk.Widget:
|
def render(self, item: ClipEntry) -> Gtk.Widget:
|
||||||
return Box(
|
return Box(
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
"""dmenu-compatible picker driven by ``sims-cli dmenu``.
|
|
||||||
|
|
||||||
The CLI writes ``request.json`` (prompt + lines) into a private directory,
|
|
||||||
listens on ``reply.sock`` in that same directory and invokes the
|
|
||||||
``open-dmenu`` action with the directory path. The menu answers by
|
|
||||||
connecting to the socket and sending the chosen line verbatim (or nothing
|
|
||||||
when cancelled), so the CLI can print it on stdout like dmenu would.
|
|
||||||
|
|
||||||
Lines produced by xdg-desktop-portal-wlr's screencast chooser
|
|
||||||
(``Monitor: <name> <description>`` / ``Window: <title> (<identifier>)``) get
|
|
||||||
a nicer rendering; everything else is shown as plain text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.image import Image
|
|
||||||
from fabric.widgets.label import Label
|
|
||||||
from gi.repository import Gtk
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from sims.services.i3 import I3, I3MessageType
|
|
||||||
|
|
||||||
from .base import FuzzyMenu
|
|
||||||
from .ranking import rank_items
|
|
||||||
|
|
||||||
ICON_SIZE = 32
|
|
||||||
|
|
||||||
_MONITOR_RE = re.compile(r"^Monitor: (?P<name>\S+)(?: (?P<desc>.*))?$")
|
|
||||||
_WINDOW_RE = re.compile(r"^Window: (?P<title>.*) \((?P<ident>[^()]*)\)$")
|
|
||||||
|
|
||||||
_WEIGHTS = {"title": 1.0, "detail": 0.6}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DmenuItem:
|
|
||||||
line: str
|
|
||||||
kind: str # "monitor" | "window" | "plain"
|
|
||||||
title: str
|
|
||||||
detail: str = ""
|
|
||||||
icon_name: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _window_app_ids() -> dict[str, str]:
|
|
||||||
"""Map window title -> app_id from fenster's tree (best effort)."""
|
|
||||||
reply = I3.send_command("", I3MessageType.GET_TREE)
|
|
||||||
if not (reply.is_ok and isinstance(reply.reply, dict)):
|
|
||||||
return {}
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
stack = [reply.reply]
|
|
||||||
while stack:
|
|
||||||
node = stack.pop()
|
|
||||||
if node.get("type") == "con" and node.get("name") and node.get("app_id"):
|
|
||||||
out.setdefault(node["name"], node["app_id"])
|
|
||||||
stack.extend(node.get("nodes", []))
|
|
||||||
stack.extend(node.get("floating_nodes", []))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _icon_for_app_id(app_id: str | None) -> str:
|
|
||||||
theme = Gtk.IconTheme.get_default()
|
|
||||||
if app_id and theme is not None:
|
|
||||||
for candidate in (app_id, app_id.lower(), app_id.rsplit(".", 1)[-1].lower()):
|
|
||||||
if theme.has_icon(candidate):
|
|
||||||
return candidate
|
|
||||||
return "application-x-executable"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_lines(lines: list[str]) -> list[DmenuItem]:
|
|
||||||
app_ids: dict[str, str] | None = None
|
|
||||||
items: list[DmenuItem] = []
|
|
||||||
for line in lines:
|
|
||||||
if m := _MONITOR_RE.match(line):
|
|
||||||
items.append(
|
|
||||||
DmenuItem(
|
|
||||||
line=line,
|
|
||||||
kind="monitor",
|
|
||||||
title=m["name"],
|
|
||||||
detail=m["desc"] or "",
|
|
||||||
icon_name="video-display",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif m := _WINDOW_RE.match(line):
|
|
||||||
if app_ids is None:
|
|
||||||
try:
|
|
||||||
app_ids = _window_app_ids()
|
|
||||||
except Exception as e: # pragma: no cover - defensive
|
|
||||||
logger.warning(f"[dmenu] could not query window tree: {e}")
|
|
||||||
app_ids = {}
|
|
||||||
app_id = app_ids.get(m["title"])
|
|
||||||
items.append(
|
|
||||||
DmenuItem(
|
|
||||||
line=line,
|
|
||||||
kind="window",
|
|
||||||
title=m["title"] or "(untitled)",
|
|
||||||
detail=app_id or "",
|
|
||||||
icon_name=_icon_for_app_id(app_id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
items.append(DmenuItem(line=line, kind="plain", title=line))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
class DmenuProvider:
|
|
||||||
def __init__(self):
|
|
||||||
self._items: list[DmenuItem] = []
|
|
||||||
self.on_select = lambda _line: None
|
|
||||||
|
|
||||||
def set_lines(self, lines: list[str]) -> None:
|
|
||||||
self._items = parse_lines(lines)
|
|
||||||
|
|
||||||
def items(self) -> list[DmenuItem]:
|
|
||||||
return list(self._items)
|
|
||||||
|
|
||||||
def filter(self, items: list[DmenuItem], query: str) -> list[DmenuItem]:
|
|
||||||
return rank_items(
|
|
||||||
items,
|
|
||||||
query,
|
|
||||||
lambda item: {"title": item.title, "detail": item.detail},
|
|
||||||
weights=_WEIGHTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, item: DmenuItem) -> Gtk.Widget:
|
|
||||||
if item.kind == "plain":
|
|
||||||
return Box(
|
|
||||||
name="slot-box",
|
|
||||||
orientation="h",
|
|
||||||
children=[Label(label=item.title, h_align="start")],
|
|
||||||
)
|
|
||||||
children: list[Gtk.Widget] = []
|
|
||||||
if item.icon_name:
|
|
||||||
children.append(
|
|
||||||
Image(name="app-icon", icon_name=item.icon_name, icon_size=ICON_SIZE)
|
|
||||||
)
|
|
||||||
text_box = Box(name="app-text", orientation="v", spacing=0)
|
|
||||||
text_box.add(
|
|
||||||
Label(label=item.title, name="app-name", h_align="start", ellipsization="end")
|
|
||||||
)
|
|
||||||
kind = "Monitor" if item.kind == "monitor" else "Window"
|
|
||||||
detail = f"{kind} · {item.detail}" if item.detail else kind
|
|
||||||
text_box.add(
|
|
||||||
Label(label=detail, name="app-generic", h_align="start", ellipsization="end")
|
|
||||||
)
|
|
||||||
children.append(text_box)
|
|
||||||
return Box(name="slot-box", orientation="h", spacing=10, children=children)
|
|
||||||
|
|
||||||
def activate(self, item: DmenuItem) -> None:
|
|
||||||
self.on_select(item.line)
|
|
||||||
|
|
||||||
|
|
||||||
class DmenuMenu(FuzzyMenu):
|
|
||||||
def __init__(self, monitor: int = 0):
|
|
||||||
# Set before FuzzyMenu/Window init, which may call hide().
|
|
||||||
self._reply_path: str | None = None
|
|
||||||
self._dmenu = DmenuProvider()
|
|
||||||
self._dmenu.on_select = self._finish
|
|
||||||
super().__init__(
|
|
||||||
provider=self._dmenu,
|
|
||||||
monitor=monitor,
|
|
||||||
placeholder="Select...",
|
|
||||||
window_name="dmenu",
|
|
||||||
max_results=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
def open_request(self, request_dir: str) -> None:
|
|
||||||
with open(os.path.join(request_dir, "request.json"), encoding="utf-8") as f:
|
|
||||||
request = json.load(f)
|
|
||||||
# A new request supersedes any pending one.
|
|
||||||
self._finish(None)
|
|
||||||
self._reply_path = os.path.join(request_dir, "reply.sock")
|
|
||||||
self._dmenu.set_lines([str(line) for line in request.get("lines", [])])
|
|
||||||
self.search_entry.set_placeholder_text(request.get("prompt") or "Select...")
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
def hide(self):
|
|
||||||
super().hide()
|
|
||||||
# Escape / Ctrl-G / activation all end up here; activation already
|
|
||||||
# replied, so this only fires for a real cancel.
|
|
||||||
self._finish(None)
|
|
||||||
|
|
||||||
def _finish(self, line: str | None) -> None:
|
|
||||||
path, self._reply_path = self._reply_path, None
|
|
||||||
if path is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.settimeout(1.0)
|
|
||||||
sock.connect(path)
|
|
||||||
if line is not None:
|
|
||||||
sock.sendall(line.encode("utf-8") + b"\n")
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"[dmenu] could not reply to {path}: {e}")
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"""Persistent launch history for the app launcher.
|
|
||||||
|
|
||||||
Remembers how often and how recently each application was launched so
|
|
||||||
``AppProvider`` can rank frequently used apps first (frecency, the
|
|
||||||
same frequency + recency idea as zoxide). The file lives in the system
|
|
||||||
cache directory - losing it only resets the ranking, never breaks the
|
|
||||||
launcher.
|
|
||||||
|
|
||||||
Layout (JSON, written atomically)::
|
|
||||||
|
|
||||||
{"version": 1, "entries": {"steam.desktop": {"count": 12, "last_used": 1712345678.0}}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from platformdirs import user_cache_dir
|
|
||||||
|
|
||||||
from .ranking import frecency, popularity_bonus
|
|
||||||
|
|
||||||
CACHE_VERSION = 1
|
|
||||||
MAX_ENTRIES = 500
|
|
||||||
MAX_AGE_DAYS = 365.0
|
|
||||||
DEFAULT_HALF_LIFE_DAYS = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
def default_cache_path() -> Path:
|
|
||||||
return Path(user_cache_dir("sims")) / "launcher-history.json"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Entry:
|
|
||||||
count: int = 0
|
|
||||||
last_used: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class LaunchHistory:
|
|
||||||
"""Launch counts/recency, keyed by launcher-specific item id."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
path: Path | str | None = None,
|
|
||||||
*,
|
|
||||||
half_life_days: float = DEFAULT_HALF_LIFE_DAYS,
|
|
||||||
):
|
|
||||||
self.path = Path(path) if path is not None else default_cache_path()
|
|
||||||
self._half_life_days = half_life_days
|
|
||||||
self._entries: dict[str, _Entry] = self._load()
|
|
||||||
|
|
||||||
def record(self, key: str, *, now: float | None = None) -> None:
|
|
||||||
"""Count one launch of *key* and persist the history."""
|
|
||||||
if not key:
|
|
||||||
return
|
|
||||||
now = time.time() if now is None else now
|
|
||||||
entry = self._entries.get(key)
|
|
||||||
if entry is None:
|
|
||||||
entry = self._entries[key] = _Entry()
|
|
||||||
entry.count += 1
|
|
||||||
entry.last_used = now
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def bonus(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
now: float | None = None,
|
|
||||||
max_bonus: float = 150.0,
|
|
||||||
) -> float:
|
|
||||||
"""Bounded popularity bonus for *key*, 0.0 when unknown."""
|
|
||||||
entry = self._entries.get(key)
|
|
||||||
if entry is None or entry.count <= 0:
|
|
||||||
return 0.0
|
|
||||||
now = time.time() if now is None else now
|
|
||||||
score = frecency(
|
|
||||||
entry.count,
|
|
||||||
entry.last_used,
|
|
||||||
now,
|
|
||||||
half_life_days=self._half_life_days,
|
|
||||||
)
|
|
||||||
return popularity_bonus(score, max_bonus=max_bonus)
|
|
||||||
|
|
||||||
# -- persistence ------------------------------------------------------
|
|
||||||
|
|
||||||
def _load(self) -> dict[str, _Entry]:
|
|
||||||
try:
|
|
||||||
with open(self.path, "r", encoding="utf-8") as handle:
|
|
||||||
data = json.load(handle)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {}
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
logger.warning("launcher history {} unreadable: {}", self.path, exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
raw_entries = data.get("entries") if isinstance(data, dict) else None
|
|
||||||
if not isinstance(raw_entries, dict):
|
|
||||||
logger.warning("launcher history {}: unexpected format", self.path)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
entries: dict[str, _Entry] = {}
|
|
||||||
for key, raw in raw_entries.items():
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
count = int(raw.get("count", 0))
|
|
||||||
last_used = float(raw.get("last_used", 0.0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if count > 0:
|
|
||||||
entries[key] = _Entry(count=count, last_used=last_used)
|
|
||||||
|
|
||||||
return self._prune(entries, time.time())
|
|
||||||
|
|
||||||
def _prune(self, entries: dict[str, _Entry], now: float) -> dict[str, _Entry]:
|
|
||||||
cutoff = now - MAX_AGE_DAYS * 86_400.0
|
|
||||||
kept = {
|
|
||||||
key: entry
|
|
||||||
for key, entry in entries.items()
|
|
||||||
if entry.count > 0 and entry.last_used >= cutoff
|
|
||||||
}
|
|
||||||
if len(kept) > MAX_ENTRIES:
|
|
||||||
top = sorted(
|
|
||||||
kept.items(),
|
|
||||||
key=lambda item: frecency(
|
|
||||||
item[1].count,
|
|
||||||
item[1].last_used,
|
|
||||||
now,
|
|
||||||
half_life_days=self._half_life_days,
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
)[:MAX_ENTRIES]
|
|
||||||
kept = dict(top)
|
|
||||||
return kept
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
|
||||||
self._entries = self._prune(self._entries, time.time())
|
|
||||||
payload = {
|
|
||||||
"version": CACHE_VERSION,
|
|
||||||
"entries": {
|
|
||||||
key: {"count": entry.count, "last_used": entry.last_used}
|
|
||||||
for key, entry in sorted(self._entries.items())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
temp_path: str | None = None
|
|
||||||
try:
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fd, temp_path = tempfile.mkstemp(
|
|
||||||
dir=self.path.parent, prefix=self.path.name, suffix=".tmp"
|
|
||||||
)
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(payload, handle)
|
|
||||||
os.replace(temp_path, self.path)
|
|
||||||
temp_path = None
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("could not write launcher history {}: {}", self.path, exc)
|
|
||||||
finally:
|
|
||||||
if temp_path is not None:
|
|
||||||
try:
|
|
||||||
os.unlink(temp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
@@ -108,7 +108,7 @@ class NotmuchSearchProvider:
|
|||||||
return hits
|
return hits
|
||||||
|
|
||||||
def render_hit(self, hit: NotmuchHit) -> Gtk.Widget:
|
def render_hit(self, hit: NotmuchHit) -> Gtk.Widget:
|
||||||
text = Box(name="notmuch-text", orientation="v", spacing=0, h_expand=True)
|
text = Box(name="notmuch-text", orientation="v", spacing=0)
|
||||||
text.add(
|
text.add(
|
||||||
Label(
|
Label(
|
||||||
label=hit.subject,
|
label=hit.subject,
|
||||||
@@ -138,7 +138,7 @@ class NotmuchSearchProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def render_saved(self, item: SavedSearch) -> Gtk.Widget:
|
def render_saved(self, item: SavedSearch) -> Gtk.Widget:
|
||||||
text = Box(name="notmuch-text", orientation="v", spacing=0, h_expand=True)
|
text = Box(name="notmuch-text", orientation="v", spacing=0)
|
||||||
text.add(
|
text.add(
|
||||||
Label(label=item.name, name="notmuch-saved-name", h_align="start")
|
Label(label=item.name, name="notmuch-saved-name", h_align="start")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
"""Search ranking for launcher items.
|
|
||||||
|
|
||||||
The launcher used to keep items in provider order (alphabetical for
|
|
||||||
desktop apps) and filter by plain substring. That breaks as soon as a
|
|
||||||
query matches several apps through different fields: typing ``steam``
|
|
||||||
listed every installed Steam game (each of their desktop files has
|
|
||||||
``Exec=steam steam://rungameid/...``) in the middle of the list, with
|
|
||||||
Steam itself somewhere in between.
|
|
||||||
|
|
||||||
This module scores a query against a set of named fields:
|
|
||||||
|
|
||||||
* every whitespace-separated token must match somewhere (AND semantics);
|
|
||||||
* match *kind* dominates the score:
|
|
||||||
exact > prefix > word start > substring > subsequence;
|
|
||||||
* fields are weighted so a match on the user-visible name outranks the
|
|
||||||
same kind of match on the generic name or the executable;
|
|
||||||
* a bounded bonus can be added on top (launch frecency, see
|
|
||||||
``history.py``). The bonus is small enough that it can never beat a
|
|
||||||
better match kind on the same field.
|
|
||||||
|
|
||||||
The score is only used for ordering, so the absolute numbers do not
|
|
||||||
matter - only the gaps between them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import IntEnum
|
|
||||||
from typing import Any, Callable, Mapping, Sequence, TypeVar
|
|
||||||
|
|
||||||
# Fuzzy (subsequence) matching on tiny queries matches almost anything,
|
|
||||||
# so only use it once the query is long enough to be meaningful.
|
|
||||||
MIN_SUBSEQUENCE_LEN = 3
|
|
||||||
|
|
||||||
Item = TypeVar("Item")
|
|
||||||
|
|
||||||
|
|
||||||
class MatchKind(IntEnum):
|
|
||||||
NONE = 0
|
|
||||||
SUBSEQUENCE = 1
|
|
||||||
SUBSTRING = 2
|
|
||||||
WORD_PREFIX = 3
|
|
||||||
PREFIX = 4
|
|
||||||
EXACT = 5
|
|
||||||
|
|
||||||
|
|
||||||
# Base score per match kind. The gaps are much larger than any quality
|
|
||||||
# or popularity bonus, so a better kind of match on the same field always
|
|
||||||
# wins over a bonus-carrying worse one.
|
|
||||||
_KIND_SCORE: Mapping[MatchKind, float] = {
|
|
||||||
MatchKind.EXACT: 1000.0,
|
|
||||||
MatchKind.PREFIX: 650.0,
|
|
||||||
MatchKind.WORD_PREFIX: 450.0,
|
|
||||||
MatchKind.SUBSTRING: 250.0,
|
|
||||||
MatchKind.SUBSEQUENCE: 90.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Weight of the match *quality* (max 75 points total):
|
|
||||||
_POSITION_WEIGHT = 25.0 # earlier in the field is better
|
|
||||||
_COVERAGE_WEIGHT = 25.0 # token covers more of the field is better
|
|
||||||
_CONTIGUITY_WEIGHT = 25.0 # subsequence: fewer skipped chars is better
|
|
||||||
|
|
||||||
# How much each desktop-entry field counts. The name must always beat
|
|
||||||
# generic-name/executable matches, otherwise every Steam game (Exec=
|
|
||||||
# "steam steam://rungameid/<id>") drowns out Steam itself.
|
|
||||||
DEFAULT_FIELD_WEIGHTS: Mapping[str, float] = {
|
|
||||||
"name": 1.0,
|
|
||||||
"display_name": 0.95,
|
|
||||||
"generic_name": 0.45,
|
|
||||||
"executable": 0.55,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FieldMatch:
|
|
||||||
"""Where and how a token matched a single field."""
|
|
||||||
|
|
||||||
kind: MatchKind
|
|
||||||
position: int
|
|
||||||
span: int
|
|
||||||
|
|
||||||
def quality(self, value_len: int, token_len: int) -> float:
|
|
||||||
value_len = max(value_len, 1)
|
|
||||||
position = 1.0 - self.position / value_len
|
|
||||||
coverage = token_len / value_len
|
|
||||||
contiguity = token_len / max(self.span, 1)
|
|
||||||
return (
|
|
||||||
_POSITION_WEIGHT * position
|
|
||||||
+ _COVERAGE_WEIGHT * coverage
|
|
||||||
+ _CONTIGUITY_WEIGHT * contiguity
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_word_start(value: str, position: int) -> bool:
|
|
||||||
return position == 0 or not value[position - 1].isalnum()
|
|
||||||
|
|
||||||
|
|
||||||
def _subsequence_span(value: str, token: str) -> tuple[int, int] | None:
|
|
||||||
"""Greedy subsequence match; returns (start, span) or None."""
|
|
||||||
first = -1
|
|
||||||
last = -1
|
|
||||||
index = 0
|
|
||||||
for position, char in enumerate(value):
|
|
||||||
if char == token[index]:
|
|
||||||
if first == -1:
|
|
||||||
first = position
|
|
||||||
last = position
|
|
||||||
index += 1
|
|
||||||
if index == len(token):
|
|
||||||
return first, last - first + 1
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def find_match(value: str, token: str) -> FieldMatch | None:
|
|
||||||
"""Best match of *token* inside *value* (both lowercased here).
|
|
||||||
|
|
||||||
Returns None when the token does not occur in the field at all.
|
|
||||||
"""
|
|
||||||
if not value or not token:
|
|
||||||
return None
|
|
||||||
value = value.lower()
|
|
||||||
token = token.lower()
|
|
||||||
|
|
||||||
if value == token:
|
|
||||||
return FieldMatch(MatchKind.EXACT, 0, len(token))
|
|
||||||
|
|
||||||
if value.startswith(token):
|
|
||||||
return FieldMatch(MatchKind.PREFIX, 0, len(token))
|
|
||||||
|
|
||||||
position = value.find(token)
|
|
||||||
if position != -1:
|
|
||||||
kind = (
|
|
||||||
MatchKind.WORD_PREFIX
|
|
||||||
if _is_word_start(value, position)
|
|
||||||
else MatchKind.SUBSTRING
|
|
||||||
)
|
|
||||||
return FieldMatch(kind, position, len(token))
|
|
||||||
|
|
||||||
if len(token) >= MIN_SUBSEQUENCE_LEN:
|
|
||||||
span = _subsequence_span(value, token)
|
|
||||||
if span is not None:
|
|
||||||
return FieldMatch(MatchKind.SUBSEQUENCE, span[0], span[1])
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def score_token(
|
|
||||||
token: str,
|
|
||||||
fields: Mapping[str, str | None],
|
|
||||||
weights: Mapping[str, float] = DEFAULT_FIELD_WEIGHTS,
|
|
||||||
) -> float | None:
|
|
||||||
"""Best weighted score of *token* across *fields*, or None."""
|
|
||||||
best: float | None = None
|
|
||||||
for field, weight in weights.items():
|
|
||||||
value = fields.get(field)
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
match = find_match(value, token)
|
|
||||||
if match is None:
|
|
||||||
continue
|
|
||||||
score = weight * (
|
|
||||||
_KIND_SCORE[match.kind] + match.quality(len(value), len(token))
|
|
||||||
)
|
|
||||||
if best is None or score > best:
|
|
||||||
best = score
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def score_query(
|
|
||||||
query: str,
|
|
||||||
fields: Mapping[str, str | None],
|
|
||||||
weights: Mapping[str, float] = DEFAULT_FIELD_WEIGHTS,
|
|
||||||
) -> float | None:
|
|
||||||
"""Score an item for *query*: None unless every token matches.
|
|
||||||
|
|
||||||
An empty query matches everything with score 0.0, which lets callers
|
|
||||||
fall back to time/frequency based ordering.
|
|
||||||
"""
|
|
||||||
tokens = query.split()
|
|
||||||
if not tokens:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
total = 0.0
|
|
||||||
for token in tokens:
|
|
||||||
token_score = score_token(token, fields, weights)
|
|
||||||
if token_score is None:
|
|
||||||
return None
|
|
||||||
total += token_score
|
|
||||||
return total / len(tokens)
|
|
||||||
|
|
||||||
|
|
||||||
def rank_items(
|
|
||||||
items: Sequence[Item],
|
|
||||||
query: str,
|
|
||||||
fields_of: Callable[[Item], Mapping[str, str | None]],
|
|
||||||
*,
|
|
||||||
weights: Mapping[str, float] = DEFAULT_FIELD_WEIGHTS,
|
|
||||||
bonus_of: Callable[[Item], float] | None = None,
|
|
||||||
tie_break_of: Callable[[Item], Any] | None = None,
|
|
||||||
) -> list[Item]:
|
|
||||||
"""Rank *items* by how well they match *query*, best first.
|
|
||||||
|
|
||||||
This is the shared filter for every launcher provider: apps, windows,
|
|
||||||
clipboard entries and static action menus all describe their searchable
|
|
||||||
text as weighted fields via *fields_of*.
|
|
||||||
|
|
||||||
With an empty query the provider order is kept, unless *bonus_of* is
|
|
||||||
given - then items are ordered by bonus instead (used by the app
|
|
||||||
launcher to show launch frecency). *bonus_of* is added to the match
|
|
||||||
score and must stay small enough not to outrank a better match kind.
|
|
||||||
Ties keep provider order, or fall back to *tie_break_of* when given.
|
|
||||||
"""
|
|
||||||
if not query.split():
|
|
||||||
if bonus_of is None:
|
|
||||||
return list(items)
|
|
||||||
scored = [(bonus_of(item), item) for item in items]
|
|
||||||
else:
|
|
||||||
scored = []
|
|
||||||
for item in items:
|
|
||||||
score = score_query(query, fields_of(item), weights)
|
|
||||||
if score is None:
|
|
||||||
continue
|
|
||||||
if bonus_of is not None:
|
|
||||||
score += bonus_of(item)
|
|
||||||
scored.append((score, item))
|
|
||||||
|
|
||||||
if tie_break_of is None:
|
|
||||||
scored.sort(key=lambda pair: -pair[0])
|
|
||||||
else:
|
|
||||||
scored.sort(key=lambda pair: (-pair[0], tie_break_of(pair[1])))
|
|
||||||
return [item for _, item in scored]
|
|
||||||
|
|
||||||
|
|
||||||
def frecency(
|
|
||||||
count: int,
|
|
||||||
last_used: float | None,
|
|
||||||
now: float,
|
|
||||||
*,
|
|
||||||
half_life_days: float = 30.0,
|
|
||||||
) -> float:
|
|
||||||
"""Launch count decayed by age with the given half-life."""
|
|
||||||
if count <= 0:
|
|
||||||
return 0.0
|
|
||||||
if last_used is None:
|
|
||||||
return float(count)
|
|
||||||
age_days = max(0.0, (now - last_used) / 86_400.0)
|
|
||||||
return count * 0.5 ** (age_days / max(half_life_days, 1e-6))
|
|
||||||
|
|
||||||
|
|
||||||
def popularity_bonus(
|
|
||||||
frecency_score: float,
|
|
||||||
*,
|
|
||||||
max_bonus: float = 150.0,
|
|
||||||
half_bonus_at: float = 5.0,
|
|
||||||
) -> float:
|
|
||||||
"""Saturating, monotonic map of frecency into ``[0, max_bonus)``.
|
|
||||||
|
|
||||||
Keeps the launch-history signal bounded so it can reorder equal
|
|
||||||
matches but never beat a better match kind.
|
|
||||||
"""
|
|
||||||
if frecency_score <= 0.0:
|
|
||||||
return 0.0
|
|
||||||
return max_bonus * frecency_score / (frecency_score + half_bonus_at)
|
|
||||||
@@ -3,15 +3,6 @@ from fabric.widgets.box import Box
|
|||||||
from fabric.widgets.label import Label
|
from fabric.widgets.label import Label
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
|
||||||
from .ranking import rank_items
|
|
||||||
|
|
||||||
|
|
||||||
# A window title is what people search for; the app id is a fallback.
|
|
||||||
# Weighted below 0.65 on purpose: an exact app-id match must not beat a
|
|
||||||
# window whose *title* starts with the query, so a query like "firefox"
|
|
||||||
# still orders the Firefox windows by their titles.
|
|
||||||
_WINDOW_WEIGHTS = {"title": 1.0, "app_id": 0.6}
|
|
||||||
|
|
||||||
|
|
||||||
class WindowProvider:
|
class WindowProvider:
|
||||||
def items(self) -> list[dict]:
|
def items(self) -> list[dict]:
|
||||||
@@ -43,17 +34,14 @@ class WindowProvider:
|
|||||||
return windows
|
return windows
|
||||||
|
|
||||||
def filter(self, items: list[dict], query: str) -> list[dict]:
|
def filter(self, items: list[dict], query: str) -> list[dict]:
|
||||||
# No history for windows - empty query keeps workspace order,
|
if not query:
|
||||||
# typed queries are ranked by title/app id match quality.
|
return items
|
||||||
return rank_items(
|
q = query.lower()
|
||||||
items,
|
return [
|
||||||
query,
|
w for w in items
|
||||||
lambda window: {
|
if q in w.get("title", "").lower()
|
||||||
"title": window.get("title", ""),
|
or q in w.get("app_id", "").lower()
|
||||||
"app_id": window.get("app_id", ""),
|
]
|
||||||
},
|
|
||||||
weights=_WINDOW_WEIGHTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, item: dict) -> Gtk.Widget:
|
def render(self, item: dict) -> Gtk.Widget:
|
||||||
title = item.get("title", "")
|
title = item.get("title", "")
|
||||||
|
|||||||
@@ -1,503 +0,0 @@
|
|||||||
"""Hover card for the bar's compact media player.
|
|
||||||
|
|
||||||
A layer-shell popup that drops down below `PlayerSmall` while the pointer
|
|
||||||
rests on it: large spinning cover, full track metadata, a seekable progress
|
|
||||||
bar, transport controls (shuffle / previous / play-pause / next / loop) and,
|
|
||||||
when more than one MPRIS player is around, a row to switch between them.
|
|
||||||
|
|
||||||
The card holds no MPRIS state of its own — `PlayerSmall` owns the tracked
|
|
||||||
players and the active selection and pushes updates in via `set_players`,
|
|
||||||
`set_active`, `refresh` and `set_cover`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.button import Button
|
|
||||||
from fabric.widgets.image import Image
|
|
||||||
from fabric.widgets.label import Label
|
|
||||||
from fabric.widgets.scale import Scale
|
|
||||||
from fabric.widgets.stack import Stack
|
|
||||||
from fabric.widgets.wayland import WaylandWindow as Window
|
|
||||||
from gi.repository import GLib, Gdk
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from sims.services.mpris import MprisPlayer
|
|
||||||
from sims.utils.artwork import file_key
|
|
||||||
from sims.widgets.circle_image import CircleImage
|
|
||||||
|
|
||||||
CARD_WIDTH = 360
|
|
||||||
COVER_SIZE = 96
|
|
||||||
PROGRESS_INTERVAL_MS = 500
|
|
||||||
|
|
||||||
PLAY_ICON = "media-playback-start-symbolic"
|
|
||||||
PAUSE_ICON = "media-playback-pause-symbolic"
|
|
||||||
PREV_ICON = "media-skip-backward-symbolic"
|
|
||||||
NEXT_ICON = "media-skip-forward-symbolic"
|
|
||||||
SHUFFLE_ICON = "media-playlist-shuffle-symbolic"
|
|
||||||
LOOP_ICON = "media-playlist-repeat-symbolic"
|
|
||||||
LOOP_TRACK_ICON = "media-playlist-repeat-song-symbolic"
|
|
||||||
FALLBACK_ICON = "audio-x-generic-symbolic"
|
|
||||||
|
|
||||||
LOOP_CYCLE = {"none": "playlist", "playlist": "track", "track": "none"}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe(fn: Callable, default=None):
|
|
||||||
"""MPRIS properties raise once the underlying player has exited (the
|
|
||||||
wrapper drops `_player`) and DBus calls can fail at any time; the card
|
|
||||||
should degrade to defaults instead of throwing out of a GTK callback."""
|
|
||||||
try:
|
|
||||||
return fn()
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _format_time(us: int) -> str:
|
|
||||||
seconds = max(0, int(us / 1_000_000))
|
|
||||||
hours, rem = divmod(seconds, 3600)
|
|
||||||
minutes, seconds = divmod(rem, 60)
|
|
||||||
if hours:
|
|
||||||
return f"{hours}:{minutes:02}:{seconds:02}"
|
|
||||||
return f"{minutes}:{seconds:02}"
|
|
||||||
|
|
||||||
|
|
||||||
def _icon_button(name: str, icon: str, size: int = 16, **kwargs) -> Button:
|
|
||||||
return Button(
|
|
||||||
name=name,
|
|
||||||
child=Image(icon_name=icon, icon_size=size),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaCard(Window):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
monitor: int | None = None,
|
|
||||||
on_select_player: Callable[[MprisPlayer], None] | None = None,
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
name="media-card",
|
|
||||||
title="sims-media-card",
|
|
||||||
layer="top",
|
|
||||||
anchor="top left",
|
|
||||||
margin=(6, 0, 0, 0),
|
|
||||||
exclusivity="none",
|
|
||||||
keyboard_mode="none",
|
|
||||||
visible=False,
|
|
||||||
monitor=monitor,
|
|
||||||
)
|
|
||||||
self._on_select_player = on_select_player
|
|
||||||
self._player: MprisPlayer | None = None
|
|
||||||
self._players: list[MprisPlayer] = []
|
|
||||||
self._progress_timer_id: int | None = None
|
|
||||||
self._spin_timer_id: int | None = None
|
|
||||||
self._seeking = False
|
|
||||||
self._cover_key: tuple[str, int] | None = None
|
|
||||||
# Player names whose shuffle / loop writes were rejected; their
|
|
||||||
# buttons stay disabled so they don't look like they do something.
|
|
||||||
self._no_shuffle: set[str] = set()
|
|
||||||
self._no_loop: set[str] = set()
|
|
||||||
|
|
||||||
# --- cover -------------------------------------------------------
|
|
||||||
self.cover = CircleImage(
|
|
||||||
name="media-card-cover", size=COVER_SIZE, h_align="center", v_align="center"
|
|
||||||
)
|
|
||||||
self.cover_fallback = Image(
|
|
||||||
name="media-card-cover-fallback", icon_name=FALLBACK_ICON, icon_size=48
|
|
||||||
)
|
|
||||||
self.cover_stack = Stack(
|
|
||||||
name="media-card-cover-stack",
|
|
||||||
transition_type="crossfade",
|
|
||||||
transition_duration=200,
|
|
||||||
children=[self.cover_fallback, self.cover],
|
|
||||||
v_align="center",
|
|
||||||
)
|
|
||||||
self.cover_stack.set_size_request(COVER_SIZE, COVER_SIZE)
|
|
||||||
self.cover_stack.set_visible_child(self.cover_fallback)
|
|
||||||
|
|
||||||
# --- metadata ----------------------------------------------------
|
|
||||||
def meta_label(name: str) -> Label:
|
|
||||||
label = Label(
|
|
||||||
name=name, h_align="fill", ellipsization="end", max_chars_width=1
|
|
||||||
)
|
|
||||||
label.set_xalign(0.0)
|
|
||||||
label.set_hexpand(True)
|
|
||||||
return label
|
|
||||||
|
|
||||||
self.player_label = meta_label("media-card-player")
|
|
||||||
self.title = meta_label("media-card-title")
|
|
||||||
self.artist = meta_label("media-card-artist")
|
|
||||||
self.album = meta_label("media-card-album")
|
|
||||||
|
|
||||||
meta = Box(
|
|
||||||
name="media-card-meta",
|
|
||||||
orientation="v",
|
|
||||||
spacing=2,
|
|
||||||
h_expand=True,
|
|
||||||
v_align="center",
|
|
||||||
children=[self.player_label, self.title, self.artist, self.album],
|
|
||||||
)
|
|
||||||
|
|
||||||
header = Box(
|
|
||||||
name="media-card-header",
|
|
||||||
orientation="h",
|
|
||||||
spacing=14,
|
|
||||||
children=[self.cover_stack, meta],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- progress ----------------------------------------------------
|
|
||||||
self.progress = Scale(
|
|
||||||
name="media-card-progress",
|
|
||||||
min_value=0.0,
|
|
||||||
max_value=1.0,
|
|
||||||
value=0.0,
|
|
||||||
draw_value=False,
|
|
||||||
h_expand=True,
|
|
||||||
increments=(0.01, 0.05),
|
|
||||||
)
|
|
||||||
self.progress.connect("button-press-event", self._on_seek_press)
|
|
||||||
self.progress.connect("button-release-event", self._on_seek_release)
|
|
||||||
# Scrolling over the card shouldn't silently scrub the track.
|
|
||||||
self.progress.connect("scroll-event", lambda *_: True)
|
|
||||||
|
|
||||||
self.time_current = Label(name="media-card-time", label="0:00", h_align="start")
|
|
||||||
self.time_total = Label(name="media-card-time", label="0:00", h_align="end")
|
|
||||||
times = Box(orientation="h", h_expand=True)
|
|
||||||
times.pack_start(self.time_current, False, False, 0)
|
|
||||||
times.pack_end(self.time_total, False, False, 0)
|
|
||||||
|
|
||||||
progress_box = Box(
|
|
||||||
name="media-card-progress-box",
|
|
||||||
orientation="v",
|
|
||||||
spacing=2,
|
|
||||||
children=[self.progress, times],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- controls ----------------------------------------------------
|
|
||||||
self.shuffle_btn = _icon_button(
|
|
||||||
"media-card-toggle", SHUFFLE_ICON, on_clicked=self._on_shuffle
|
|
||||||
)
|
|
||||||
self.prev_btn = _icon_button(
|
|
||||||
"media-card-btn", PREV_ICON, size=20, on_clicked=self._on_prev
|
|
||||||
)
|
|
||||||
self.play_image = Image(icon_name=PLAY_ICON, icon_size=24)
|
|
||||||
self.play_btn = Button(
|
|
||||||
name="media-card-play", child=self.play_image, on_clicked=self._on_play_pause
|
|
||||||
)
|
|
||||||
self.next_btn = _icon_button(
|
|
||||||
"media-card-btn", NEXT_ICON, size=20, on_clicked=self._on_next
|
|
||||||
)
|
|
||||||
self.loop_image = Image(icon_name=LOOP_ICON, icon_size=16)
|
|
||||||
self.loop_btn = Button(
|
|
||||||
name="media-card-toggle", child=self.loop_image, on_clicked=self._on_loop
|
|
||||||
)
|
|
||||||
|
|
||||||
self.shuffle_btn.set_tooltip_text("Shuffle")
|
|
||||||
self.prev_btn.set_tooltip_text("Previous")
|
|
||||||
self.next_btn.set_tooltip_text("Next")
|
|
||||||
self.loop_btn.set_tooltip_text("Repeat")
|
|
||||||
|
|
||||||
controls = Box(
|
|
||||||
name="media-card-controls",
|
|
||||||
orientation="h",
|
|
||||||
spacing=10,
|
|
||||||
h_align="center",
|
|
||||||
children=[
|
|
||||||
self.shuffle_btn,
|
|
||||||
self.prev_btn,
|
|
||||||
self.play_btn,
|
|
||||||
self.next_btn,
|
|
||||||
self.loop_btn,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- player switcher (only shown with >1 player) -----------------
|
|
||||||
self.players_box = Box(
|
|
||||||
name="media-card-players", orientation="h", spacing=6, h_align="center"
|
|
||||||
)
|
|
||||||
self.players_box.set_no_show_all(True)
|
|
||||||
|
|
||||||
self.body = Box(
|
|
||||||
name="media-card-body",
|
|
||||||
orientation="v",
|
|
||||||
spacing=12,
|
|
||||||
children=[header, progress_box, controls, self.players_box],
|
|
||||||
)
|
|
||||||
self.body.set_size_request(CARD_WIDTH, -1)
|
|
||||||
self.add(self.body)
|
|
||||||
|
|
||||||
self.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
|
|
||||||
self.connect("map", lambda *_: self._on_mapped())
|
|
||||||
self.connect("unmap", lambda *_: self._on_unmapped())
|
|
||||||
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# public API (driven by PlayerSmall)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def player(self) -> MprisPlayer | None:
|
|
||||||
return self._player
|
|
||||||
|
|
||||||
@property
|
|
||||||
def seeking(self) -> bool:
|
|
||||||
return self._seeking
|
|
||||||
|
|
||||||
def popup(self, x: int) -> None:
|
|
||||||
"""Show the card with its left edge at `x` (output coordinates)."""
|
|
||||||
self.margin = (6, 0, 0, max(0, x))
|
|
||||||
if not self.get_visible():
|
|
||||||
# Not show_all(): that would resurrect labels/rows refresh() hid.
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
def popdown(self) -> None:
|
|
||||||
if self.get_visible():
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def set_players(self, players: list[MprisPlayer]) -> None:
|
|
||||||
self._players = list(players)
|
|
||||||
self._rebuild_player_switcher()
|
|
||||||
|
|
||||||
def set_active(self, player: MprisPlayer | None) -> None:
|
|
||||||
self._player = player
|
|
||||||
self._seeking = False
|
|
||||||
self._sync_player_switcher()
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_cover(self, path: str | None) -> None:
|
|
||||||
key = file_key(path)
|
|
||||||
if key is not None and key == self._cover_key:
|
|
||||||
return
|
|
||||||
self._cover_key = None
|
|
||||||
if key is not None:
|
|
||||||
try:
|
|
||||||
self.cover.set_image_from_file(path)
|
|
||||||
self._cover_key = key
|
|
||||||
self.cover_stack.set_visible_child(self.cover)
|
|
||||||
self._update_spin()
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[MediaCard] failed to load cover {path}: {e}")
|
|
||||||
self.cover_stack.set_visible_child(self.cover_fallback)
|
|
||||||
self._update_spin()
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
|
||||||
mp = self._player
|
|
||||||
if mp is None:
|
|
||||||
self.player_label.set_text("")
|
|
||||||
self.title.set_text("Nothing playing")
|
|
||||||
self.artist.set_text("")
|
|
||||||
self.album.set_text("")
|
|
||||||
self.artist.set_visible(False)
|
|
||||||
self.album.set_visible(False)
|
|
||||||
self.play_image.set_property("icon-name", PLAY_ICON)
|
|
||||||
for btn in (self.shuffle_btn, self.prev_btn, self.play_btn, self.next_btn, self.loop_btn):
|
|
||||||
btn.set_sensitive(False)
|
|
||||||
self.progress.set_sensitive(False)
|
|
||||||
self.progress.set_value(0.0)
|
|
||||||
self.time_current.set_text("0:00")
|
|
||||||
self.time_total.set_text("0:00")
|
|
||||||
self.cover_stack.set_visible_child(self.cover_fallback)
|
|
||||||
self._cover_key = None
|
|
||||||
self._update_spin()
|
|
||||||
return
|
|
||||||
|
|
||||||
name = _safe(lambda: mp.player_name, "") or ""
|
|
||||||
self.player_label.set_text(name.upper())
|
|
||||||
|
|
||||||
title = (_safe(lambda: mp.title, "") or "").strip()
|
|
||||||
artist = (_safe(lambda: mp.artist, "") or "").strip()
|
|
||||||
album = (_safe(lambda: mp.album, "") or "").strip()
|
|
||||||
self.title.set_text(title or "Unknown title")
|
|
||||||
self.title.set_tooltip_text(title or None)
|
|
||||||
self.artist.set_text(artist)
|
|
||||||
self.artist.set_visible(bool(artist))
|
|
||||||
self.album.set_text(album)
|
|
||||||
self.album.set_visible(bool(album))
|
|
||||||
|
|
||||||
playing = _safe(lambda: mp.playback_status, "") == "playing"
|
|
||||||
self.play_image.set_property("icon-name", PAUSE_ICON if playing else PLAY_ICON)
|
|
||||||
self.play_btn.set_tooltip_text("Pause" if playing else "Play")
|
|
||||||
|
|
||||||
self.play_btn.set_sensitive(bool(_safe(lambda: mp.can_pause, False)))
|
|
||||||
self.prev_btn.set_sensitive(bool(_safe(lambda: mp.can_go_previous, False)))
|
|
||||||
self.next_btn.set_sensitive(bool(_safe(lambda: mp.can_go_next, False)))
|
|
||||||
|
|
||||||
self.shuffle_btn.set_sensitive(name not in self._no_shuffle)
|
|
||||||
self._set_class(self.shuffle_btn, "active", bool(_safe(lambda: mp.shuffle, False)))
|
|
||||||
|
|
||||||
loop = _safe(lambda: mp.loop_status, "none")
|
|
||||||
self.loop_btn.set_sensitive(name not in self._no_loop)
|
|
||||||
self.loop_image.set_property(
|
|
||||||
"icon-name", LOOP_TRACK_ICON if loop == "track" else LOOP_ICON
|
|
||||||
)
|
|
||||||
self._set_class(self.loop_btn, "active", loop in ("playlist", "track"))
|
|
||||||
self.loop_btn.set_tooltip_text(
|
|
||||||
{"playlist": "Repeat: playlist", "track": "Repeat: track"}.get(
|
|
||||||
loop, "Repeat: off"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self._update_progress()
|
|
||||||
self._update_spin()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# player switcher
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _rebuild_player_switcher(self) -> None:
|
|
||||||
for child in self.players_box.get_children():
|
|
||||||
self.players_box.remove(child)
|
|
||||||
child.destroy()
|
|
||||||
|
|
||||||
if len(self._players) < 2:
|
|
||||||
self.players_box.hide()
|
|
||||||
return
|
|
||||||
|
|
||||||
for mp in self._players:
|
|
||||||
label = _safe(lambda mp=mp: mp.player_name, "?") or "?"
|
|
||||||
btn = Button(
|
|
||||||
name="media-card-player-chip",
|
|
||||||
child=Label(label=label),
|
|
||||||
on_clicked=lambda *_, mp=mp: self._select(mp),
|
|
||||||
)
|
|
||||||
btn.player = mp
|
|
||||||
self.players_box.add(btn)
|
|
||||||
btn.show_all()
|
|
||||||
self.players_box.show()
|
|
||||||
self._sync_player_switcher()
|
|
||||||
|
|
||||||
def _sync_player_switcher(self) -> None:
|
|
||||||
for btn in self.players_box.get_children():
|
|
||||||
self._set_class(btn, "active", getattr(btn, "player", None) is self._player)
|
|
||||||
|
|
||||||
def _select(self, mp: MprisPlayer) -> None:
|
|
||||||
if self._on_select_player is not None:
|
|
||||||
self._on_select_player(mp)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# controls
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _on_play_pause(self, *_):
|
|
||||||
if self._player is not None:
|
|
||||||
_safe(self._player.play_pause)
|
|
||||||
|
|
||||||
def _on_prev(self, *_):
|
|
||||||
if self._player is not None:
|
|
||||||
_safe(self._player.previous)
|
|
||||||
|
|
||||||
def _on_next(self, *_):
|
|
||||||
if self._player is not None:
|
|
||||||
_safe(self._player.next)
|
|
||||||
|
|
||||||
def _on_shuffle(self, *_):
|
|
||||||
mp = self._player
|
|
||||||
if mp is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
mp.set_shuffle(not mp.shuffle)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[MediaCard] shuffle unsupported by {mp.player_name}: {e}")
|
|
||||||
self._no_shuffle.add(mp.player_name)
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def _on_loop(self, *_):
|
|
||||||
mp = self._player
|
|
||||||
if mp is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
mp.set_loop(LOOP_CYCLE.get(mp.loop_status, "none"))
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[MediaCard] loop unsupported by {mp.player_name}: {e}")
|
|
||||||
self._no_loop.add(mp.player_name)
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# progress / seeking
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _length(self) -> int:
|
|
||||||
mp = self._player
|
|
||||||
if mp is None:
|
|
||||||
return 0
|
|
||||||
return _safe(lambda: int(mp.length or 0), 0)
|
|
||||||
|
|
||||||
def _update_progress(self) -> bool:
|
|
||||||
mp = self._player
|
|
||||||
total = self._length()
|
|
||||||
can_seek = bool(mp is not None and _safe(lambda: mp.can_seek, False))
|
|
||||||
self.progress.set_sensitive(can_seek and total > 0)
|
|
||||||
|
|
||||||
if mp is None or total <= 0:
|
|
||||||
if not self._seeking:
|
|
||||||
self.progress.set_value(0.0)
|
|
||||||
self.time_current.set_text("0:00" if mp is None else "--:--")
|
|
||||||
self.time_total.set_text("0:00" if mp is None else "--:--")
|
|
||||||
return True
|
|
||||||
|
|
||||||
self.time_total.set_text(_format_time(total))
|
|
||||||
if self._seeking:
|
|
||||||
# While dragging, show where the thumb is instead of the player.
|
|
||||||
self.time_current.set_text(_format_time(self.progress.get_value() * total))
|
|
||||||
return True
|
|
||||||
|
|
||||||
position = _safe(lambda: mp.position, 0) or 0
|
|
||||||
self.progress.set_value(min(1.0, max(0.0, position / total)))
|
|
||||||
self.time_current.set_text(_format_time(position))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _on_seek_press(self, *_):
|
|
||||||
self._seeking = True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _on_seek_release(self, *_):
|
|
||||||
if self._seeking and self._player is not None:
|
|
||||||
total = self._length()
|
|
||||||
if total > 0:
|
|
||||||
target = int(self.progress.get_value() * total)
|
|
||||||
_safe(lambda: setattr(self._player, "position", target))
|
|
||||||
self._seeking = False
|
|
||||||
# Let the scale finish its own release handling before we re-sync.
|
|
||||||
GLib.timeout_add(150, lambda: (self._update_progress(), False)[1])
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# timers — only run while the card is on screen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _on_mapped(self):
|
|
||||||
self._update_progress()
|
|
||||||
if self._progress_timer_id is None:
|
|
||||||
self._progress_timer_id = GLib.timeout_add(
|
|
||||||
PROGRESS_INTERVAL_MS, self._update_progress
|
|
||||||
)
|
|
||||||
self._update_spin()
|
|
||||||
|
|
||||||
def _on_unmapped(self):
|
|
||||||
if self._progress_timer_id is not None:
|
|
||||||
GLib.source_remove(self._progress_timer_id)
|
|
||||||
self._progress_timer_id = None
|
|
||||||
self._seeking = False
|
|
||||||
self._update_spin()
|
|
||||||
|
|
||||||
def _update_spin(self):
|
|
||||||
should_spin = (
|
|
||||||
self.get_mapped()
|
|
||||||
and self._player is not None
|
|
||||||
and self.cover_stack.get_visible_child() is self.cover
|
|
||||||
and _safe(lambda: self._player.playback_status, "") == "playing"
|
|
||||||
)
|
|
||||||
if should_spin and self._spin_timer_id is None:
|
|
||||||
self._spin_timer_id = GLib.timeout_add(50, self._advance_spin)
|
|
||||||
elif not should_spin and self._spin_timer_id is not None:
|
|
||||||
GLib.source_remove(self._spin_timer_id)
|
|
||||||
self._spin_timer_id = None
|
|
||||||
|
|
||||||
def _advance_spin(self):
|
|
||||||
self.cover.angle = (self.cover.angle + 1) % 360
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _set_class(widget, cls: str, on: bool) -> None:
|
|
||||||
ctx = widget.get_style_context()
|
|
||||||
if on and not ctx.has_class(cls):
|
|
||||||
ctx.add_class(cls)
|
|
||||||
elif not on and ctx.has_class(cls):
|
|
||||||
ctx.remove_class(cls)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.button import Button
|
|
||||||
from fabric.widgets.label import Label
|
|
||||||
from sims.services.audio import get_audio_service
|
|
||||||
|
|
||||||
MIC_ICON = ""
|
|
||||||
MIC_MUTED_ICON = ""
|
|
||||||
|
|
||||||
|
|
||||||
class MicrophoneWidget(Box):
|
|
||||||
"""Bar widget showing microphone state; click to toggle mute.
|
|
||||||
|
|
||||||
Mutes *all* input sources (not just the default one) so virtual
|
|
||||||
sources like EasyEffects and their underlying hardware mics are
|
|
||||||
silenced together.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.audio = get_audio_service()
|
|
||||||
|
|
||||||
self.icon_label = Label(
|
|
||||||
name="microphone-icon",
|
|
||||||
label=MIC_ICON,
|
|
||||||
)
|
|
||||||
self.button = Button(
|
|
||||||
name="microphone-button",
|
|
||||||
child=self.icon_label,
|
|
||||||
on_clicked=self.on_clicked,
|
|
||||||
)
|
|
||||||
self.add(self.button)
|
|
||||||
|
|
||||||
# `changed` fires for any stream change (incl. mute state) and
|
|
||||||
# `microphone-changed` when the default source switches.
|
|
||||||
self.audio.connect("changed", self.update_icon)
|
|
||||||
self.audio.connect("microphone-changed", self.update_icon)
|
|
||||||
self.update_icon()
|
|
||||||
|
|
||||||
def on_clicked(self, *_):
|
|
||||||
mics = self.audio.microphones or []
|
|
||||||
if not mics and self.audio.microphone:
|
|
||||||
mics = [self.audio.microphone]
|
|
||||||
if not mics:
|
|
||||||
return
|
|
||||||
# if anything is live -> mute everything; else unmute everything
|
|
||||||
mute = self._any_unmuted(mics)
|
|
||||||
for mic in mics:
|
|
||||||
mic.muted = mute
|
|
||||||
self.update_icon()
|
|
||||||
|
|
||||||
def _any_unmuted(self, mics) -> bool:
|
|
||||||
return any(not mic.muted for mic in mics)
|
|
||||||
|
|
||||||
def update_icon(self, *_):
|
|
||||||
mics = self.audio.microphones or []
|
|
||||||
if not mics and self.audio.microphone:
|
|
||||||
mics = [self.audio.microphone]
|
|
||||||
if not mics:
|
|
||||||
self.set_visible(False)
|
|
||||||
return
|
|
||||||
self.set_visible(True)
|
|
||||||
if self._any_unmuted(mics):
|
|
||||||
self.icon_label.set_label(MIC_ICON)
|
|
||||||
self.icon_label.remove_style_class("muted")
|
|
||||||
else:
|
|
||||||
self.icon_label.set_label(MIC_MUTED_ICON)
|
|
||||||
self.icon_label.add_style_class("muted")
|
|
||||||
+3
-19
@@ -137,7 +137,7 @@ class NotmuchService:
|
|||||||
class NotmuchWidget(Button):
|
class NotmuchWidget(Button):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
# Create the widget content
|
# Create the widget content
|
||||||
self.icon = Image(name="notmuch-icon", icon_name="mail-unread-symbolic", icon_size=16)
|
self.icon = Image(icon_name="mail-unread-symbolic", icon_size=16)
|
||||||
self.label = Label("0", name="unread-count")
|
self.label = Label("0", name="unread-count")
|
||||||
|
|
||||||
# Container for icon and label
|
# Container for icon and label
|
||||||
@@ -155,7 +155,7 @@ class NotmuchWidget(Button):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Initialize the service
|
# Initialize the service
|
||||||
self.service = get_notmuch_service()
|
self.service = NotmuchService()
|
||||||
self.service.connect("counts-changed", self.update_display)
|
self.service.connect("counts-changed", self.update_display)
|
||||||
|
|
||||||
logger.info("[Notmuch] Notmuch widget initialized")
|
logger.info("[Notmuch] Notmuch widget initialized")
|
||||||
@@ -205,20 +205,4 @@ class NotmuchWidget(Button):
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Notmuch] Updated display: {unread} unread, {debt} aging — classes={classes}"
|
f"[Notmuch] Updated display: {unread} unread, {debt} aging — classes={classes}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_service: "NotmuchService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_notmuch_service(update_interval: int = 60000) -> "NotmuchService":
|
|
||||||
"""Return the process-wide notmuch service, creating it on first use.
|
|
||||||
|
|
||||||
Each poll shells out to `notmuch count` twice. Every bar widget used to own
|
|
||||||
a service, so one `refresh-notmuch` (or one timer tick) opened the database
|
|
||||||
2 x bars times over; now all widgets share a single poller.
|
|
||||||
"""
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = NotmuchService(update_interval=update_interval)
|
|
||||||
return _service
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
"""Org-agenda bar widget + dropdown popup.
|
|
||||||
|
|
||||||
Bar slot is a small button showing an icon + count of open items. Clicking
|
|
||||||
toggles a layer-shell dropdown listing items grouped by state. Clicking an
|
|
||||||
item opens emacsclient at the headline's line.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.button import Button
|
|
||||||
from fabric.widgets.image import Image
|
|
||||||
from fabric.widgets.label import Label
|
|
||||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
|
||||||
from fabric.widgets.wayland import WaylandWindow as Window
|
|
||||||
from gi.repository import Gdk
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from sims.config import BAR_HEIGHT, ORG
|
|
||||||
from sims.services.org import OrgItem, OrgService, OrgSnapshot
|
|
||||||
|
|
||||||
|
|
||||||
# Order in which states are shown / counted. Anything else discovered at parse
|
|
||||||
# time is appended after these.
|
|
||||||
DEFAULT_STATE_ORDER = ["NEXT", "IN-PROGRESS", "TODO"]
|
|
||||||
|
|
||||||
|
|
||||||
def _state_order(states: list[str]) -> list[str]:
|
|
||||||
seen = set()
|
|
||||||
ordered: list[str] = []
|
|
||||||
for s in DEFAULT_STATE_ORDER:
|
|
||||||
if s in states and s not in seen:
|
|
||||||
ordered.append(s)
|
|
||||||
seen.add(s)
|
|
||||||
for s in states:
|
|
||||||
if s not in seen:
|
|
||||||
ordered.append(s)
|
|
||||||
seen.add(s)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
def _open_in_emacs(item: OrgItem) -> None:
|
|
||||||
cmd_template = ORG.get("emacsclient_command", "emacsclient")
|
|
||||||
parts = cmd_template.split() if isinstance(cmd_template, str) else list(cmd_template)
|
|
||||||
cmd = [*parts, "-c", f"+{item.line}", item.file]
|
|
||||||
try:
|
|
||||||
subprocess.Popen(cmd, start_new_session=True)
|
|
||||||
logger.info(f"[Org] Opened {item.file}:{item.line} in emacsclient")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Org] Failed to open emacsclient: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class OrgDropdown(Window):
|
|
||||||
def __init__(self, service: OrgService, monitor: int = 0):
|
|
||||||
width = int(ORG.get("dropdown_width", 420))
|
|
||||||
super().__init__(
|
|
||||||
name="org-dropdown",
|
|
||||||
layer="top",
|
|
||||||
anchor="top",
|
|
||||||
margin=f"{BAR_HEIGHT}px 0 0 0",
|
|
||||||
keyboard_mode="on-demand",
|
|
||||||
exclusivity="none",
|
|
||||||
visible=False,
|
|
||||||
monitor=monitor,
|
|
||||||
)
|
|
||||||
self._service = service
|
|
||||||
self._width = width
|
|
||||||
|
|
||||||
self._header = Label(
|
|
||||||
name="org-dropdown-title",
|
|
||||||
label="Org Agenda",
|
|
||||||
h_align="start",
|
|
||||||
)
|
|
||||||
close_button = Button(
|
|
||||||
name="org-dropdown-close",
|
|
||||||
image=Image(icon_name="window-close-symbolic", icon_size=16),
|
|
||||||
on_clicked=lambda *_: self.hide(),
|
|
||||||
)
|
|
||||||
header_row = Box(name="org-dropdown-header", orientation="h", spacing=8)
|
|
||||||
header_row.pack_start(self._header, True, True, 0)
|
|
||||||
header_row.pack_end(close_button, False, False, 0)
|
|
||||||
|
|
||||||
self._sections_box = Box(
|
|
||||||
name="org-dropdown-sections",
|
|
||||||
orientation="v",
|
|
||||||
spacing=10,
|
|
||||||
h_expand=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
scroll = ScrolledWindow(
|
|
||||||
name="org-dropdown-scroll",
|
|
||||||
h_scrollbar_policy="never",
|
|
||||||
v_scrollbar_policy="automatic",
|
|
||||||
child=self._sections_box,
|
|
||||||
h_expand=True,
|
|
||||||
v_expand=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
body = Box(
|
|
||||||
name="org-dropdown-body",
|
|
||||||
orientation="v",
|
|
||||||
spacing=8,
|
|
||||||
children=[header_row, scroll],
|
|
||||||
h_expand=True,
|
|
||||||
v_expand=True,
|
|
||||||
)
|
|
||||||
body.set_size_request(self._width, int(ORG.get("dropdown_height", 480)))
|
|
||||||
self.add(body)
|
|
||||||
|
|
||||||
self.connect("key-press-event", self._on_key_press)
|
|
||||||
self._service.connect("items-changed", lambda _s, snap: self._refresh(snap))
|
|
||||||
self._refresh(self._service.snapshot)
|
|
||||||
|
|
||||||
def toggle(self) -> None:
|
|
||||||
if self.get_visible():
|
|
||||||
self.hide()
|
|
||||||
else:
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
def show(self) -> None: # type: ignore[override]
|
|
||||||
super().show()
|
|
||||||
self.show_all()
|
|
||||||
|
|
||||||
def _on_key_press(self, _w, event):
|
|
||||||
if event.keyval == Gdk.KEY_Escape:
|
|
||||||
self.hide()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _refresh(self, snapshot: OrgSnapshot) -> None:
|
|
||||||
for child in self._sections_box.get_children():
|
|
||||||
self._sections_box.remove(child)
|
|
||||||
child.destroy()
|
|
||||||
|
|
||||||
if not snapshot.items:
|
|
||||||
self._sections_box.add(
|
|
||||||
Label(
|
|
||||||
name="org-dropdown-empty",
|
|
||||||
label="Nothing on the agenda.",
|
|
||||||
h_align="start",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._sections_box.show_all()
|
|
||||||
return
|
|
||||||
|
|
||||||
by_state: dict[str, list[OrgItem]] = {}
|
|
||||||
for it in snapshot.items:
|
|
||||||
by_state.setdefault(it.state, []).append(it)
|
|
||||||
|
|
||||||
for state in _state_order(list(by_state.keys())):
|
|
||||||
items = by_state.get(state, [])
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
section = self._build_section(state, items)
|
|
||||||
self._sections_box.add(section)
|
|
||||||
|
|
||||||
self._sections_box.show_all()
|
|
||||||
|
|
||||||
def _build_section(self, state: str, items: list[OrgItem]) -> Box:
|
|
||||||
section = Box(
|
|
||||||
name="org-dropdown-section",
|
|
||||||
orientation="v",
|
|
||||||
spacing=4,
|
|
||||||
style_classes=["org-state", f"org-state-{state.lower()}"],
|
|
||||||
)
|
|
||||||
header = Box(orientation="h", spacing=6)
|
|
||||||
header.add(
|
|
||||||
Label(
|
|
||||||
name="org-dropdown-section-title",
|
|
||||||
label=state,
|
|
||||||
h_align="start",
|
|
||||||
style_classes=["org-state-label"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
header.add(
|
|
||||||
Label(
|
|
||||||
label=f"({len(items)})",
|
|
||||||
h_align="start",
|
|
||||||
style_classes=["org-state-count"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
section.add(header)
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
section.add(self._build_item(item))
|
|
||||||
return section
|
|
||||||
|
|
||||||
def _build_item(self, item: OrgItem) -> Button:
|
|
||||||
title_parts = []
|
|
||||||
if item.priority:
|
|
||||||
title_parts.append(f"#{item.priority}")
|
|
||||||
title_parts.append(item.headline)
|
|
||||||
if item.tags:
|
|
||||||
title_parts.append(":" + ":".join(item.tags) + ":")
|
|
||||||
title = " ".join(title_parts)
|
|
||||||
|
|
||||||
title_label = Label(
|
|
||||||
label=title,
|
|
||||||
h_align="start",
|
|
||||||
ellipsization="end",
|
|
||||||
style_classes=["org-item-title"],
|
|
||||||
)
|
|
||||||
|
|
||||||
meta_bits = []
|
|
||||||
if item.scheduled:
|
|
||||||
meta_bits.append(f"S: {item.scheduled}")
|
|
||||||
if item.deadline:
|
|
||||||
meta_bits.append(f"D: {item.deadline}")
|
|
||||||
meta_bits.append(os.path.basename(item.file))
|
|
||||||
|
|
||||||
meta_label = Label(
|
|
||||||
label=" · ".join(meta_bits),
|
|
||||||
h_align="start",
|
|
||||||
style_classes=["org-item-meta"],
|
|
||||||
)
|
|
||||||
|
|
||||||
content = Box(orientation="v", spacing=2, h_expand=True)
|
|
||||||
content.add(title_label)
|
|
||||||
content.add(meta_label)
|
|
||||||
|
|
||||||
button = Button(
|
|
||||||
name="org-item",
|
|
||||||
child=content,
|
|
||||||
on_clicked=lambda *_: self._on_item_clicked(item),
|
|
||||||
style_classes=["org-item"],
|
|
||||||
)
|
|
||||||
return button
|
|
||||||
|
|
||||||
def _on_item_clicked(self, item: OrgItem) -> None:
|
|
||||||
_open_in_emacs(item)
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
|
|
||||||
class OrgWidget(Button):
|
|
||||||
def __init__(self, service: OrgService, dropdown: OrgDropdown | None, **kwargs):
|
|
||||||
self._service = service
|
|
||||||
self._dropdown = dropdown
|
|
||||||
|
|
||||||
self.icon = Label(label="✓", name="org-icon")
|
|
||||||
self.label = Label("0", name="org-count")
|
|
||||||
|
|
||||||
container = Box(orientation="h", spacing=4, children=[self.icon, self.label])
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
name="org-widget",
|
|
||||||
child=container,
|
|
||||||
on_clicked=lambda *_: self._on_clicked(),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._service.connect("items-changed", lambda _s, snap: self._refresh(snap))
|
|
||||||
self._refresh(self._service.snapshot)
|
|
||||||
|
|
||||||
def _on_clicked(self) -> None:
|
|
||||||
if self._dropdown is not None:
|
|
||||||
self._dropdown.toggle()
|
|
||||||
|
|
||||||
def _refresh(self, snapshot: OrgSnapshot) -> None:
|
|
||||||
counted = list(ORG.get("counted_states", ["TODO", "NEXT", "IN-PROGRESS"]))
|
|
||||||
total = snapshot.total_counted(counted)
|
|
||||||
next_count = snapshot.counts.get("NEXT", 0)
|
|
||||||
in_progress = snapshot.counts.get("IN-PROGRESS", 0)
|
|
||||||
|
|
||||||
self.label.set_text(str(total))
|
|
||||||
|
|
||||||
classes = ["org-widget"]
|
|
||||||
if total == 0:
|
|
||||||
classes.append("empty")
|
|
||||||
if next_count > 0:
|
|
||||||
classes.append("has-next")
|
|
||||||
if in_progress > 0:
|
|
||||||
classes.append("has-in-progress")
|
|
||||||
self.set_style_classes(classes)
|
|
||||||
|
|
||||||
tooltip_lines = []
|
|
||||||
for state in _state_order(list(snapshot.counts.keys())):
|
|
||||||
n = snapshot.counts.get(state, 0)
|
|
||||||
if n:
|
|
||||||
tooltip_lines.append(f"{state}: {n}")
|
|
||||||
self.set_tooltip_text("\n".join(tooltip_lines) if tooltip_lines else "No open org items")
|
|
||||||
+39
-177
@@ -12,12 +12,9 @@ from fabric.widgets.image import Image
|
|||||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||||
from fabric.widgets.overlay import Overlay
|
from fabric.widgets.overlay import Overlay
|
||||||
from fabric.widgets.stack import Stack
|
from fabric.widgets.stack import Stack
|
||||||
from fabric.widgets.eventbox import EventBox
|
|
||||||
from ..widgets.circle_image import CircleImage
|
from ..widgets.circle_image import CircleImage
|
||||||
from sims.modules.media_card import MediaCard
|
|
||||||
from sims.utils.artwork import file_key, resolve_artwork
|
|
||||||
import sims.modules.icons as icons
|
import sims.modules.icons as icons
|
||||||
from sims.services.mpris import MprisPlayer, get_mpris_player_manager
|
from sims.services.mpris import MprisPlayerManager, MprisPlayer
|
||||||
from fabric import Fabricator
|
from fabric import Fabricator
|
||||||
|
|
||||||
# from sims.modules.cavalcade import SpectrumRender
|
# from sims.modules.cavalcade import SpectrumRender
|
||||||
@@ -437,7 +434,7 @@ class Player(Box):
|
|||||||
)
|
)
|
||||||
self.switcher.set_stack(self.player_stack)
|
self.switcher.set_stack(self.player_stack)
|
||||||
self.switcher.set_halign(Gtk.Align.CENTER)
|
self.switcher.set_halign(Gtk.Align.CENTER)
|
||||||
self.mpris_manager = get_mpris_player_manager()
|
self.mpris_manager = MprisPlayerManager()
|
||||||
players = self.mpris_manager.players
|
players = self.mpris_manager.players
|
||||||
if players:
|
if players:
|
||||||
for p in players:
|
for p in players:
|
||||||
@@ -527,41 +524,18 @@ class Player(Box):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class PlayerSmall(EventBox):
|
class PlayerSmall(Box):
|
||||||
"""Compact bar pill: spinning cover, title/artist and a play button.
|
|
||||||
|
|
||||||
Hovering the pill drops down a `MediaCard` with the full controls; the
|
|
||||||
card stays open while the pointer is on either the pill or the card.
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLAY_ICON = "media-playback-start-symbolic"
|
PLAY_ICON = "media-playback-start-symbolic"
|
||||||
PAUSE_ICON = "media-playback-pause-symbolic"
|
PAUSE_ICON = "media-playback-pause-symbolic"
|
||||||
PREV_ICON = "media-skip-backward-symbolic"
|
PREV_ICON = "media-skip-backward-symbolic"
|
||||||
NEXT_ICON = "media-skip-forward-symbolic"
|
NEXT_ICON = "media-skip-forward-symbolic"
|
||||||
FALLBACK_ICON = "audio-x-generic-symbolic"
|
FALLBACK_ICON = "audio-x-generic-symbolic"
|
||||||
COVER_SIZE = 22
|
COVER_SIZE = 22
|
||||||
# Delay before the card opens (so sweeping across the bar doesn't flash
|
|
||||||
# it) and before it closes (so the pointer can cross the gap between the
|
|
||||||
# pill and the card).
|
|
||||||
CARD_SHOW_DELAY_MS = 250
|
|
||||||
CARD_HIDE_DELAY_MS = 300
|
|
||||||
|
|
||||||
def __init__(self, monitor: int | None = None):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
events=["enter-notify", "leave-notify"], v_align="center"
|
name="player-small", orientation="h", v_align="center"
|
||||||
)
|
)
|
||||||
self.pill = Box(name="player-small", orientation="h", v_align="center")
|
|
||||||
self.add(self.pill)
|
|
||||||
|
|
||||||
self._monitor = monitor
|
|
||||||
self._card: MediaCard | None = None
|
|
||||||
self._hover_pill = False
|
|
||||||
self._hover_card = False
|
|
||||||
self._card_show_id = None
|
|
||||||
self._card_hide_id = None
|
|
||||||
self._cover_url = None
|
|
||||||
self._cover_path = None
|
|
||||||
self._cover_key = None
|
|
||||||
self._display_options = ["title", "artist"]
|
self._display_options = ["title", "artist"]
|
||||||
self._display_index = 0
|
self._display_index = 0
|
||||||
self._current_display = "title"
|
self._current_display = "title"
|
||||||
@@ -647,12 +621,9 @@ class PlayerSmall(EventBox):
|
|||||||
end_children=self.mpris_button,
|
end_children=self.mpris_button,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.pill.add(self.mpris_small)
|
self.add(self.mpris_small)
|
||||||
|
|
||||||
self.connect("enter-notify-event", self._on_pill_enter)
|
self.mpris_manager = MprisPlayerManager()
|
||||||
self.connect("leave-notify-event", self._on_pill_leave)
|
|
||||||
|
|
||||||
self.mpris_manager = get_mpris_player_manager()
|
|
||||||
self.mpris_player = None
|
self.mpris_player = None
|
||||||
self._players = {}
|
self._players = {}
|
||||||
self._player_handlers = {}
|
self._player_handlers = {}
|
||||||
@@ -747,23 +718,16 @@ class PlayerSmall(EventBox):
|
|||||||
self._width_tween_id = GLib.timeout_add(16, tick)
|
self._width_tween_id = GLib.timeout_add(16, tick)
|
||||||
|
|
||||||
def _set_cover_from_path(self, image_path):
|
def _set_cover_from_path(self, image_path):
|
||||||
loaded = False
|
if image_path and os.path.isfile(image_path):
|
||||||
key = file_key(image_path)
|
try:
|
||||||
if key is not None:
|
self.cover.set_image_from_file(image_path)
|
||||||
if key == self._cover_key:
|
self.cover_stack.set_visible_child(self.cover)
|
||||||
loaded = True # already showing it; skip re-decoding
|
self._update_spin()
|
||||||
else:
|
return False
|
||||||
try:
|
except Exception:
|
||||||
self.cover.set_image_from_file(image_path)
|
pass
|
||||||
loaded = True
|
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._cover_path = image_path if loaded else None
|
|
||||||
self._cover_key = key if loaded else None
|
|
||||||
self.cover_stack.set_visible_child(self.cover if loaded else self.fallback_icon)
|
|
||||||
self._update_spin()
|
self._update_spin()
|
||||||
if self._card is not None:
|
|
||||||
self._card.set_cover(self._cover_path)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _start_spin(self):
|
def _start_spin(self):
|
||||||
@@ -792,31 +756,40 @@ class PlayerSmall(EventBox):
|
|||||||
else:
|
else:
|
||||||
self._stop_spin()
|
self._stop_spin()
|
||||||
|
|
||||||
def _update_cover(self, mp):
|
def _download_artwork(self, arturl):
|
||||||
try:
|
try:
|
||||||
url = mp.arturl if mp else None
|
parsed = urllib.parse.urlparse(arturl)
|
||||||
|
suffix = os.path.splitext(parsed.path)[1] or ".png"
|
||||||
|
with urllib.request.urlopen(arturl) as response:
|
||||||
|
data = response.read()
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||||
|
tmp.write(data)
|
||||||
|
tmp.close()
|
||||||
|
local_path = tmp.name
|
||||||
except Exception:
|
except Exception:
|
||||||
url = None
|
local_path = None
|
||||||
self._cover_url = url
|
GLib.idle_add(self._set_cover_from_path, local_path)
|
||||||
|
return None
|
||||||
|
|
||||||
def done(path):
|
def _update_cover(self, mp):
|
||||||
# The track may have changed while a download was in flight.
|
if not mp or not mp.arturl:
|
||||||
if self._cover_url == url:
|
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||||
self._set_cover_from_path(path)
|
return
|
||||||
|
parsed = urllib.parse.urlparse(mp.arturl)
|
||||||
resolve_artwork(url, done)
|
if parsed.scheme == "file":
|
||||||
|
self._set_cover_from_path(urllib.parse.unquote(parsed.path))
|
||||||
|
elif parsed.scheme in ("http", "https"):
|
||||||
|
GLib.Thread.new("compact-artwork", self._download_artwork, mp.arturl)
|
||||||
|
else:
|
||||||
|
self._set_cover_from_path(mp.arturl)
|
||||||
|
|
||||||
def _apply_mpris_properties(self):
|
def _apply_mpris_properties(self):
|
||||||
if not self.mpris_player:
|
if not self.mpris_player:
|
||||||
self._set_label_text("")
|
self._set_label_text("")
|
||||||
self.play_image.set_property("icon-name", self.PLAY_ICON)
|
self.play_image.set_property("icon-name", self.PLAY_ICON)
|
||||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||||
self._cover_url = None
|
|
||||||
self._cover_path = None
|
|
||||||
self._cover_key = None
|
|
||||||
self.center_stack.set_visible_child(self.mpris_label)
|
self.center_stack.set_visible_child(self.mpris_label)
|
||||||
self._update_spin()
|
self._update_spin()
|
||||||
self._sync_card()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
mp = self.mpris_player
|
mp = self.mpris_player
|
||||||
@@ -829,113 +802,6 @@ class PlayerSmall(EventBox):
|
|||||||
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
|
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
|
||||||
self._set_label_text(text)
|
self._set_label_text(text)
|
||||||
self.center_stack.set_visible_child(self.mpris_label)
|
self.center_stack.set_visible_child(self.mpris_label)
|
||||||
self._sync_card()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# hover card
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _ensure_card(self) -> MediaCard:
|
|
||||||
if self._card is None:
|
|
||||||
card = MediaCard(monitor=self._monitor, on_select_player=self._select_player)
|
|
||||||
card.connect("enter-notify-event", self._on_card_enter)
|
|
||||||
card.connect("leave-notify-event", self._on_card_leave)
|
|
||||||
card.set_players(list(self._players.values()))
|
|
||||||
card.set_active(self.mpris_player)
|
|
||||||
card.set_cover(self._cover_path)
|
|
||||||
self.connect("destroy", lambda *_: card.destroy())
|
|
||||||
self._card = card
|
|
||||||
return self._card
|
|
||||||
|
|
||||||
def _sync_card(self):
|
|
||||||
card = self._card
|
|
||||||
if card is None:
|
|
||||||
return
|
|
||||||
if card.player is not self.mpris_player:
|
|
||||||
card.set_active(self.mpris_player)
|
|
||||||
card.set_cover(self._cover_path)
|
|
||||||
else:
|
|
||||||
card.refresh()
|
|
||||||
if self.mpris_player is None:
|
|
||||||
card.popdown()
|
|
||||||
|
|
||||||
def _select_player(self, mp):
|
|
||||||
if mp in self._players.values() and mp is not self.mpris_player:
|
|
||||||
self.mpris_player = mp
|
|
||||||
self._apply_mpris_properties()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_grab_crossing(event) -> bool:
|
|
||||||
# GTK synthesizes crossing events when a widget grabs the pointer
|
|
||||||
# (e.g. while dragging the card's seek bar); those aren't the pointer
|
|
||||||
# actually leaving.
|
|
||||||
return event.mode in (Gdk.CrossingMode.GRAB, Gdk.CrossingMode.GTK_GRAB)
|
|
||||||
|
|
||||||
def _on_pill_enter(self, _widget, _event):
|
|
||||||
self._hover_pill = True
|
|
||||||
self._cancel_card_hide()
|
|
||||||
card_open = self._card is not None and self._card.get_visible()
|
|
||||||
if not card_open and self._card_show_id is None:
|
|
||||||
self._card_show_id = GLib.timeout_add(
|
|
||||||
self.CARD_SHOW_DELAY_MS, self._show_card
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _on_pill_leave(self, _widget, event):
|
|
||||||
if event.detail == Gdk.NotifyType.INFERIOR or self._is_grab_crossing(event):
|
|
||||||
return False
|
|
||||||
self._hover_pill = False
|
|
||||||
if self._card_show_id is not None:
|
|
||||||
GLib.source_remove(self._card_show_id)
|
|
||||||
self._card_show_id = None
|
|
||||||
self._schedule_card_hide()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _on_card_enter(self, _widget, _event):
|
|
||||||
self._hover_card = True
|
|
||||||
self._cancel_card_hide()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _on_card_leave(self, _widget, event):
|
|
||||||
if event.detail == Gdk.NotifyType.INFERIOR or self._is_grab_crossing(event):
|
|
||||||
return False
|
|
||||||
self._hover_card = False
|
|
||||||
self._schedule_card_hide()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _show_card(self):
|
|
||||||
self._card_show_id = None
|
|
||||||
if not self._hover_pill or not self._players or self.mpris_player is None:
|
|
||||||
return False
|
|
||||||
card = self._ensure_card()
|
|
||||||
res = self.translate_coordinates(self.get_toplevel(), 0, 0)
|
|
||||||
# PyGObject returns (x, y), or (ok, x, y) depending on version.
|
|
||||||
x = 0
|
|
||||||
if res:
|
|
||||||
x = res[1] if len(res) == 3 else res[0]
|
|
||||||
card.popup(int(x))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _schedule_card_hide(self):
|
|
||||||
if self._card_hide_id is None:
|
|
||||||
self._card_hide_id = GLib.timeout_add(
|
|
||||||
self.CARD_HIDE_DELAY_MS, self._maybe_hide_card
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cancel_card_hide(self):
|
|
||||||
if self._card_hide_id is not None:
|
|
||||||
GLib.source_remove(self._card_hide_id)
|
|
||||||
self._card_hide_id = None
|
|
||||||
|
|
||||||
def _maybe_hide_card(self):
|
|
||||||
self._card_hide_id = None
|
|
||||||
if self._hover_pill or self._hover_card or self._card is None:
|
|
||||||
return False
|
|
||||||
if self._card.seeking:
|
|
||||||
# Pointer slipped off while scrubbing; close once released.
|
|
||||||
self._schedule_card_hide()
|
|
||||||
return False
|
|
||||||
self._card.popdown()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _on_icon_button_press(self, widget, event):
|
def _on_icon_button_press(self, widget, event):
|
||||||
if event.type != Gdk.EventType.BUTTON_PRESS:
|
if event.type != Gdk.EventType.BUTTON_PRESS:
|
||||||
@@ -1002,8 +868,6 @@ class PlayerSmall(EventBox):
|
|||||||
self._players[name] = mp
|
self._players[name] = mp
|
||||||
self._player_handlers[name] = handler_id
|
self._player_handlers[name] = handler_id
|
||||||
self._last_status[name] = mp.playback_status
|
self._last_status[name] = mp.playback_status
|
||||||
if self._card is not None:
|
|
||||||
self._card.set_players(list(self._players.values()))
|
|
||||||
|
|
||||||
def _untrack_player(self, name):
|
def _untrack_player(self, name):
|
||||||
mp = self._players.pop(name, None)
|
mp = self._players.pop(name, None)
|
||||||
@@ -1012,8 +876,6 @@ class PlayerSmall(EventBox):
|
|||||||
if mp and handler_id is not None:
|
if mp and handler_id is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
mp.disconnect(handler_id)
|
mp.disconnect(handler_id)
|
||||||
if self._card is not None:
|
|
||||||
self._card.set_players(list(self._players.values()))
|
|
||||||
|
|
||||||
def _select_initial_player(self):
|
def _select_initial_player(self):
|
||||||
for mp in self._players.values():
|
for mp in self._players.values():
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from fabric.utils import invoke_repeater
|
||||||
|
from fabric.widgets.box import Box
|
||||||
|
from fabric.widgets.button import Button
|
||||||
|
from fabric.widgets.label import Label
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from sims.services.screenrec import ScreenrecService
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenrecWidget(Button):
|
||||||
|
def __init__(self, service: ScreenrecService, **kwargs):
|
||||||
|
self._service = service
|
||||||
|
self._timer_id: int | None = None
|
||||||
|
|
||||||
|
self._dot = Label(name="screenrec-dot", label="●")
|
||||||
|
self._elapsed = Label(name="screenrec-elapsed", label="00:00")
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
name="screenrec-widget",
|
||||||
|
child=Box(
|
||||||
|
orientation="h",
|
||||||
|
spacing=6,
|
||||||
|
children=[self._dot, self._elapsed],
|
||||||
|
),
|
||||||
|
on_clicked=lambda *_: self._service.stop(),
|
||||||
|
visible=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self.set_no_show_all(True)
|
||||||
|
|
||||||
|
self._service.connect("recording-changed", self._on_recording_changed)
|
||||||
|
if self._service.recording:
|
||||||
|
self._on_recording_changed(self._service, True)
|
||||||
|
|
||||||
|
def _on_recording_changed(self, _service, recording: bool):
|
||||||
|
if recording:
|
||||||
|
self._update_elapsed()
|
||||||
|
self.set_visible(True)
|
||||||
|
if self._timer_id is None:
|
||||||
|
self._timer_id = invoke_repeater(1000, self._update_elapsed)
|
||||||
|
else:
|
||||||
|
self.set_visible(False)
|
||||||
|
if self._timer_id is not None:
|
||||||
|
try:
|
||||||
|
GLib.source_remove(self._timer_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._timer_id = None
|
||||||
|
|
||||||
|
def _update_elapsed(self) -> bool:
|
||||||
|
started = self._service.started_at
|
||||||
|
if started is None:
|
||||||
|
self._elapsed.set_text("00:00")
|
||||||
|
return True
|
||||||
|
secs = int(time.monotonic() - started)
|
||||||
|
self._elapsed.set_text(f"{secs // 60:02d}:{secs % 60:02d}")
|
||||||
|
return True
|
||||||
+220
-113
@@ -1,73 +1,41 @@
|
|||||||
from sims.config import STYLIX
|
from sims.config import STYLIX
|
||||||
from loguru import logger
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
# Default colors if Stylix is not properly configured
|
def generate_stylix_css():
|
||||||
DEFAULT_COLORS = {
|
"""Generate CSS using Stylix colors if enabled"""
|
||||||
"base00": "1e1e2e", # background
|
|
||||||
"base01": "313244", # lighter background
|
|
||||||
"base02": "45475a", # selection background
|
|
||||||
"base03": "585b70", # comments
|
|
||||||
"base04": "bac2de", # dark foreground
|
|
||||||
"base05": "cdd6f4", # foreground
|
|
||||||
"base06": "f5e0dc", # light foreground
|
|
||||||
"base07": "b4befe", # light background
|
|
||||||
"base08": "f38ba8", # red
|
|
||||||
"base09": "fab387", # orange
|
|
||||||
"base0A": "f9e2af", # yellow
|
|
||||||
"base0B": "a6e3a1", # green
|
|
||||||
"base0C": "94e2d5", # cyan
|
|
||||||
"base0D": "89b4fa", # blue
|
|
||||||
"base0E": "cba6f7", # purple
|
|
||||||
"base0F": "f2cdcd", # brown
|
|
||||||
}
|
|
||||||
|
|
||||||
# styles/colors.css palette name -> base16 slot. fabric compiles `:vars` to
|
|
||||||
# GTK `@define-color`, and named colors resolve across the whole style
|
|
||||||
# cascade, so redefining them in the stylix provider (added after main.css)
|
|
||||||
# retargets every `var(--x)` rule in styles/*.css as well -- not just the
|
|
||||||
# selectors overridden below.
|
|
||||||
PALETTE = {
|
|
||||||
"background": "base00",
|
|
||||||
"mid-bg": "base01",
|
|
||||||
"light-bg": "base02",
|
|
||||||
"dark-grey": "base03",
|
|
||||||
"light-grey": "base04",
|
|
||||||
"dark-fg": "base04",
|
|
||||||
"mid-fg": "base05",
|
|
||||||
"foreground": "base05",
|
|
||||||
"red": "base08",
|
|
||||||
"orange": "base09",
|
|
||||||
"gold": "base0A",
|
|
||||||
"lime": "base0B",
|
|
||||||
"turquoise": "base0C",
|
|
||||||
"blue": "base0D",
|
|
||||||
"violet": "base0E",
|
|
||||||
"pink": "base0F",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_colors(mode: str | None = None) -> dict[str, str]:
|
|
||||||
"""base16 colors for `mode` ("light"/"dark"): the matching
|
|
||||||
`stylix.variants.<mode>.colors`, then the base `stylix.colors`, then
|
|
||||||
DEFAULT_COLORS for any slot still missing."""
|
|
||||||
colors = dict(DEFAULT_COLORS)
|
|
||||||
if STYLIX.get("enable", False):
|
|
||||||
colors.update(STYLIX.get("colors") or {})
|
|
||||||
if mode is not None:
|
|
||||||
variant = (STYLIX.get("variants") or {}).get(mode) or {}
|
|
||||||
colors.update(variant.get("colors") or {})
|
|
||||||
return colors
|
|
||||||
|
|
||||||
|
|
||||||
def generate_stylix_css(mode: str | None = None) -> str | None:
|
|
||||||
"""Generate CSS using Stylix colors (for `mode`, if variants are set)"""
|
|
||||||
if not STYLIX.get("enable", False):
|
if not STYLIX.get("enable", False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
colors = resolve_colors(mode)
|
colors = STYLIX.get("colors", {})
|
||||||
fonts = STYLIX.get("fonts", {})
|
fonts = STYLIX.get("fonts", {})
|
||||||
|
|
||||||
|
# Default colors if Stylix is not properly configured
|
||||||
|
default_colors = {
|
||||||
|
"base00": "1e1e2e", # background
|
||||||
|
"base01": "313244", # lighter background
|
||||||
|
"base02": "45475a", # selection background
|
||||||
|
"base03": "585b70", # comments
|
||||||
|
"base04": "bac2de", # dark foreground
|
||||||
|
"base05": "cdd6f4", # foreground
|
||||||
|
"base06": "f5e0dc", # light foreground
|
||||||
|
"base07": "b4befe", # light background
|
||||||
|
"base08": "f38ba8", # red
|
||||||
|
"base09": "fab387", # orange
|
||||||
|
"base0A": "f9e2af", # yellow
|
||||||
|
"base0B": "a6e3a1", # green
|
||||||
|
"base0C": "94e2d5", # cyan
|
||||||
|
"base0D": "89b4fa", # blue
|
||||||
|
"base0E": "cba6f7", # purple
|
||||||
|
"base0F": "f2cdcd", # brown
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use Stylix colors or fallback to defaults
|
||||||
|
for key in default_colors:
|
||||||
|
if key not in colors:
|
||||||
|
colors[key] = default_colors[key]
|
||||||
|
|
||||||
# Default font
|
# Default font
|
||||||
font_family = fonts.get("sansSerif", "sans-serif")
|
font_family = fonts.get("sansSerif", "sans-serif")
|
||||||
font_sizes = fonts.get("sizes", {})
|
font_sizes = fonts.get("sizes", {})
|
||||||
@@ -78,41 +46,108 @@ def generate_stylix_css(mode: str | None = None) -> str | None:
|
|||||||
small_font = max(int(font_size * 0.85), 10) # Minimum 10px
|
small_font = max(int(font_size * 0.85), 10) # Minimum 10px
|
||||||
large_font = int(font_size * 1.1)
|
large_font = int(font_size * 1.1)
|
||||||
|
|
||||||
logger.info(
|
# Debug logging
|
||||||
f"[Stylix] Generating CSS (mode={mode}) - Base: {font_size}px, "
|
from loguru import logger
|
||||||
f"Small: {small_font}px, Large: {large_font}px"
|
logger.info(f"[Stylix] Using font sizes - Base: {font_size}px, Small: {small_font}px, Large: {large_font}px")
|
||||||
)
|
|
||||||
|
|
||||||
palette = "\n".join(
|
|
||||||
f"@define-color {name} #{colors[slot]};" for name, slot in PALETTE.items()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate GTK CSS with Stylix colors
|
# Generate GTK CSS with Stylix colors
|
||||||
css_content = f"""/* Stylix-generated theme */
|
css_content = f"""/* Stylix-generated theme */
|
||||||
|
|
||||||
{palette}
|
/* Apply Stylix font */
|
||||||
|
|
||||||
/* Apply Stylix font. This provider sits in front of main.css and GTK lets
|
|
||||||
the first provider that sets a property win regardless of selector
|
|
||||||
specificity, so a blanket `* {{ font-size }}` here would flatten every
|
|
||||||
size in the bar's own CSS. The bar (window#sims) therefore only gets the
|
|
||||||
size on its toplevel and inherits it; other windows keep the blanket
|
|
||||||
rule they were designed against. */
|
|
||||||
* {{
|
* {{
|
||||||
font-family: "{font_family}", sans-serif;
|
font-family: "{font_family}", sans-serif;
|
||||||
}}
|
|
||||||
|
|
||||||
window,
|
|
||||||
tooltip {{
|
|
||||||
font-size: {font_size}px;
|
font-size: {font_size}px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
window:not(#sims) * {{
|
/* Bar styling */
|
||||||
|
#sims-inner {{
|
||||||
|
padding: 4px;
|
||||||
|
border-bottom: solid 2px;
|
||||||
|
border-color: #{colors["base02"]};
|
||||||
|
background-color: #{colors["base00"]};
|
||||||
|
border-radius: 0;
|
||||||
|
transition: border-radius 200ms ease;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#sims-inner.rounded-bottom {{
|
||||||
|
border-radius: 0 0 28px 28px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#center-container {{
|
||||||
|
color: #{colors["base05"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
.active-window {{
|
||||||
|
color: #{colors["base05"]};
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Battery */
|
||||||
|
#battery-widget {{
|
||||||
|
background-color: #{colors["base01"]};
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#bat-icon {{
|
||||||
|
color: #{colors["base0D"]};
|
||||||
|
margin-right: 2px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#bat-label {{
|
||||||
|
color: #{colors["base05"]};
|
||||||
font-size: {font_size}px;
|
font-size: {font_size}px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* Bar widgets are styled purely in the static styles/ css through the palette
|
#bat-label.battery-low {{
|
||||||
above (see styles/colors.css for the semantic tokens). */
|
color: #{colors["base08"]};
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Progress bars */
|
||||||
|
#cpu-progress-bar,
|
||||||
|
#ram-progress-bar,
|
||||||
|
#volume-progress-bar {{
|
||||||
|
color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#cpu-progress-bar {{
|
||||||
|
border: solid 0px alpha(#{colors["base0E"]}, 0.8);
|
||||||
|
}}
|
||||||
|
|
||||||
|
#ram-progress-bar,
|
||||||
|
#volume-progress-bar {{
|
||||||
|
border: solid 0px #{colors["base0D"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Widgets container */
|
||||||
|
#widgets-container {{
|
||||||
|
background-color: #{colors["base01"]};
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* NixOS label */
|
||||||
|
#nixos-label {{
|
||||||
|
color: #{colors["base0D"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Date time */
|
||||||
|
#date-time {{
|
||||||
|
color: #{colors["base05"]};
|
||||||
|
background-color: #{colors["base01"]};
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#date-time-button {{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}}
|
||||||
|
|
||||||
/* Generic popup styling */
|
/* Generic popup styling */
|
||||||
.popup-window,
|
.popup-window,
|
||||||
@@ -216,6 +251,92 @@ window:not(#sims) * {{
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* Tooltips */
|
||||||
|
tooltip {{
|
||||||
|
border: solid 2px;
|
||||||
|
border-color: #{colors["base02"]};
|
||||||
|
background-color: #{colors["base00"]};
|
||||||
|
color: #{colors["base05"]};
|
||||||
|
border-radius: 16px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
tooltip>* {{
|
||||||
|
padding: 2px 4px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Workspaces */
|
||||||
|
#workspaces {{
|
||||||
|
background-color: #{colors["base01"]};
|
||||||
|
padding: 6px 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#workspaces>button {{
|
||||||
|
background-color: #{colors["base05"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#workspaces>button.empty:not(.active):not(.visible) {{
|
||||||
|
background-color: #{colors["base03"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#workspaces>button.visible:not(.active) {{
|
||||||
|
background-color: #{colors["base0E"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#workspaces>button.active {{
|
||||||
|
background-color: #{colors["base0D"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#workspaces>button.urgent {{
|
||||||
|
background-color: #{colors["base08"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Workspace shown on a different monitor than this bar — dim it. */
|
||||||
|
#workspaces>button.foreign {{
|
||||||
|
opacity: 0.45;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#workspaces>button>label {{
|
||||||
|
font-size: 0px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Compact MPRIS player */
|
||||||
|
#player-small {{
|
||||||
|
background-color: #{colors["base01"]};
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 100px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#compact-mpris-icon,
|
||||||
|
#compact-mpris-button {{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#compact-mpris-button-icon,
|
||||||
|
#compact-mpris-fallback {{
|
||||||
|
color: #{colors["base05"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#compact-mpris-button:hover #compact-mpris-button-icon {{
|
||||||
|
color: #{colors["base0D"]};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#compact-mpris-fallback {{
|
||||||
|
opacity: 0.7;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#compact-mpris-label {{
|
||||||
|
color: #{colors["base05"]};
|
||||||
|
font-size: {font_size}px;
|
||||||
|
margin: 0 6px;
|
||||||
|
}}
|
||||||
|
|
||||||
/* Quick Menu styling */
|
/* Quick Menu styling */
|
||||||
#quick-menu-container {{
|
#quick-menu-container {{
|
||||||
background-color: #{colors["base00"]};
|
background-color: #{colors["base00"]};
|
||||||
@@ -270,33 +391,6 @@ window:not(#sims) * {{
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* Theme (darkman light/dark) toggle */
|
|
||||||
#theme-toggle-button {{
|
|
||||||
background-color: transparent;
|
|
||||||
color: #{colors["base05"]};
|
|
||||||
border: none;
|
|
||||||
padding: 4px;
|
|
||||||
margin: 0px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
#theme-toggle-button:hover {{
|
|
||||||
background-color: #{colors["base02"]};
|
|
||||||
}}
|
|
||||||
|
|
||||||
#theme-toggle-button.active {{
|
|
||||||
background-color: #{colors["base0D"]};
|
|
||||||
color: #{colors["base00"]};
|
|
||||||
}}
|
|
||||||
|
|
||||||
#theme-toggle-button:disabled {{
|
|
||||||
opacity: 0.4;
|
|
||||||
}}
|
|
||||||
|
|
||||||
#theme-toggle-icon {{
|
|
||||||
color: inherit;
|
|
||||||
}}
|
|
||||||
|
|
||||||
/* Toggle switch styling for quick menu */
|
/* Toggle switch styling for quick menu */
|
||||||
.toggle-active {{
|
.toggle-active {{
|
||||||
background-color: #{colors["base0B"]};
|
background-color: #{colors["base0B"]};
|
||||||
@@ -365,4 +459,17 @@ window:not(#sims) * {{
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return css_content
|
# Write to temporary file
|
||||||
|
temp_fd, temp_path = tempfile.mkstemp(suffix=".css", prefix="stylix_")
|
||||||
|
try:
|
||||||
|
with os.fdopen(temp_fd, "w") as f:
|
||||||
|
f.write(css_content)
|
||||||
|
return temp_path
|
||||||
|
except Exception:
|
||||||
|
os.close(temp_fd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_stylix_css_path():
|
||||||
|
"""Get the path to the Stylix CSS file"""
|
||||||
|
return generate_stylix_css()
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
from fabric.widgets.button import Button
|
|
||||||
from fabric.widgets.image import Image
|
|
||||||
|
|
||||||
from sims.services.darkman import DarkmanService
|
|
||||||
|
|
||||||
|
|
||||||
class ThemeToggleButton(Button):
|
|
||||||
"""Flips darkman between light and dark mode.
|
|
||||||
|
|
||||||
Shows the active mode (moon + `active` class in dark mode) and is
|
|
||||||
insensitive while darkman is unreachable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, service: DarkmanService, **kwargs):
|
|
||||||
self._service = service
|
|
||||||
self.icon = Image(icon_size=16, name="theme-toggle-icon")
|
|
||||||
super().__init__(
|
|
||||||
name="theme-toggle-button",
|
|
||||||
child=self.icon,
|
|
||||||
on_clicked=lambda *_: self._service.toggle(),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self._service.connect("changed", lambda _svc, mode: self._update(mode))
|
|
||||||
self._update(self._service.mode)
|
|
||||||
|
|
||||||
def _update(self, mode: str | None):
|
|
||||||
dark = mode == "dark"
|
|
||||||
self.icon.set_from_icon_name(
|
|
||||||
"weather-clear-night-symbolic" if dark else "weather-clear-symbolic", 16
|
|
||||||
)
|
|
||||||
if dark:
|
|
||||||
self.add_style_class("active")
|
|
||||||
else:
|
|
||||||
self.remove_style_class("active")
|
|
||||||
self.set_sensitive(mode is not None)
|
|
||||||
if mode is None:
|
|
||||||
self.set_tooltip_text("darkman is not running")
|
|
||||||
else:
|
|
||||||
self.set_tooltip_text(f"Switch to {'light' if dark else 'dark'} mode")
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Shared audio service.
|
|
||||||
|
|
||||||
fabric's `Audio` wraps a libcvc (`Cvc.MixerControl`) client: constructing one
|
|
||||||
opens a PulseAudio/PipeWire connection, subscribes to every stream event and
|
|
||||||
enumerates sinks/sources. Widgets were creating one per output, so keep a
|
|
||||||
single process-wide instance instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fabric.audio.service import Audio
|
|
||||||
|
|
||||||
_service: "Audio | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_audio_service() -> "Audio":
|
|
||||||
"""Return the process-wide Audio service, creating it on first use."""
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = Audio()
|
|
||||||
return _service
|
|
||||||
@@ -69,19 +69,4 @@ class BatteryService(Service):
|
|||||||
@property
|
@property
|
||||||
def charging(self):
|
def charging(self):
|
||||||
"""Get current charging status"""
|
"""Get current charging status"""
|
||||||
return self._charging
|
return self._charging
|
||||||
|
|
||||||
|
|
||||||
_service: "BatteryService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_battery_service(update_interval: int = 10000) -> "BatteryService":
|
|
||||||
"""Return the process-wide battery service, creating it on first use.
|
|
||||||
|
|
||||||
Only the first caller's `update_interval` wins, so the bar widget (10s) and
|
|
||||||
the buddy (15s) share one psutil poll instead of running their own.
|
|
||||||
"""
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = BatteryService(update_interval=update_interval)
|
|
||||||
return _service
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Mood arbitration for the bar buddy.
|
|
||||||
|
|
||||||
Fuses CPU load, battery state, time of day, mail counts and click events into a
|
|
||||||
single `mood-changed` signal. Highest-priority active mood wins.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fabric.core.service import Service, Signal
|
|
||||||
from fabric.utils import invoke_repeater
|
|
||||||
|
|
||||||
from sims.services.battery import get_battery_service
|
|
||||||
from sims.services.mpris import MprisPlayer, get_mpris_player_manager
|
|
||||||
from sims.services.system_stats import get_system_stats_service
|
|
||||||
from sims.config import BATTERY, NOTMUCH
|
|
||||||
|
|
||||||
|
|
||||||
# Highest priority first. The first mood whose state is True wins.
|
|
||||||
MOOD_PRIORITY = ["petted", "excited", "busy", "bopping", "sleepy", "idle"]
|
|
||||||
|
|
||||||
PETTED_DURATION_S = 1.5
|
|
||||||
EXCITED_DURATION_S = 5.0
|
|
||||||
BUSY_CPU_THRESHOLD = 0.80
|
|
||||||
SLEEPY_BATTERY_THRESHOLD = 20 # percent
|
|
||||||
SLEEPY_HOUR_START = 23 # 23:00 inclusive
|
|
||||||
SLEEPY_HOUR_END = 7 # exclusive
|
|
||||||
TICK_INTERVAL_MS = 1000
|
|
||||||
|
|
||||||
|
|
||||||
class BuddyService(Service):
|
|
||||||
@Signal
|
|
||||||
def mood_changed(self, mood: str) -> None:
|
|
||||||
"""Emitted when the arbitrated mood changes."""
|
|
||||||
|
|
||||||
def __init__(self, notmuch_service=None, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._mood = "idle"
|
|
||||||
self._cpu = 0.0
|
|
||||||
self._battery_pct = 100
|
|
||||||
self._battery_charging = True
|
|
||||||
self._petted_until = 0.0
|
|
||||||
self._excited_until = 0.0
|
|
||||||
self._last_unread = None
|
|
||||||
|
|
||||||
self._stats = get_system_stats_service()
|
|
||||||
self._stats.connect("stats-changed", self._on_stats)
|
|
||||||
|
|
||||||
self._battery = None
|
|
||||||
if BATTERY.get("enable", False):
|
|
||||||
self._battery = get_battery_service()
|
|
||||||
self._battery.connect("battery-changed", self._on_battery)
|
|
||||||
|
|
||||||
self._notmuch = notmuch_service
|
|
||||||
if self._notmuch is not None:
|
|
||||||
self._notmuch.connect("counts-changed", self._on_notmuch)
|
|
||||||
|
|
||||||
self._mpris_manager = get_mpris_player_manager()
|
|
||||||
self._mpris_players: dict[str, MprisPlayer] = {}
|
|
||||||
for p in self._mpris_manager.players or []:
|
|
||||||
self._attach_player(p)
|
|
||||||
self._mpris_manager.connect("player-appeared", self._on_player_appeared)
|
|
||||||
self._mpris_manager.connect("player-vanished", self._on_player_vanished)
|
|
||||||
|
|
||||||
invoke_repeater(TICK_INTERVAL_MS, self._tick)
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
# External triggers ---------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mood(self) -> str:
|
|
||||||
"""Currently arbitrated mood."""
|
|
||||||
return self._mood
|
|
||||||
|
|
||||||
def pet(self):
|
|
||||||
"""Called when the buddy is clicked."""
|
|
||||||
self._petted_until = time.monotonic() + PETTED_DURATION_S
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
# Service callbacks ---------------------------------------------------
|
|
||||||
|
|
||||||
def _on_stats(self, _service, cpu_fraction, _mem_fraction):
|
|
||||||
self._cpu = cpu_fraction
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _on_battery(self, _service, percent, charging):
|
|
||||||
self._battery_pct = percent
|
|
||||||
self._battery_charging = charging
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _on_notmuch(self, _service, unread, _debt):
|
|
||||||
if self._last_unread is not None and unread > self._last_unread:
|
|
||||||
self._excited_until = time.monotonic() + EXCITED_DURATION_S
|
|
||||||
self._last_unread = unread
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _attach_player(self, player):
|
|
||||||
mp = MprisPlayer(player)
|
|
||||||
self._mpris_players[mp.player_name] = mp
|
|
||||||
mp.connect("changed", self._on_player_changed)
|
|
||||||
|
|
||||||
def _on_player_appeared(self, _manager, player):
|
|
||||||
self._attach_player(player)
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _on_player_vanished(self, _manager, player_name: str):
|
|
||||||
self._mpris_players.pop(player_name, None)
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _on_player_changed(self, _player):
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _is_anything_playing(self) -> bool:
|
|
||||||
for mp in self._mpris_players.values():
|
|
||||||
try:
|
|
||||||
if mp.playback_status == "playing":
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _tick(self):
|
|
||||||
# Re-arbitrate every second so transient moods (petted/excited) and
|
|
||||||
# the clock-driven sleepy window expire correctly.
|
|
||||||
self._recompute()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Arbitration ---------------------------------------------------------
|
|
||||||
|
|
||||||
def _states(self) -> dict[str, bool]:
|
|
||||||
now = time.monotonic()
|
|
||||||
hour = datetime.now().hour
|
|
||||||
is_night = hour >= SLEEPY_HOUR_START or hour < SLEEPY_HOUR_END
|
|
||||||
battery_low = (self._battery is not None
|
|
||||||
and self._battery_pct < SLEEPY_BATTERY_THRESHOLD
|
|
||||||
and not self._battery_charging)
|
|
||||||
return {
|
|
||||||
"petted": now < self._petted_until,
|
|
||||||
"excited": now < self._excited_until,
|
|
||||||
"busy": self._cpu > BUSY_CPU_THRESHOLD,
|
|
||||||
"bopping": self._is_anything_playing(),
|
|
||||||
"sleepy": battery_low or is_night,
|
|
||||||
"idle": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _recompute(self):
|
|
||||||
states = self._states()
|
|
||||||
for mood in MOOD_PRIORITY:
|
|
||||||
if states.get(mood, False):
|
|
||||||
if mood != self._mood:
|
|
||||||
self._mood = mood
|
|
||||||
self.mood_changed(mood)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
_service: "BuddyService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_buddy_service(notmuch_service=None) -> "BuddyService":
|
|
||||||
"""Return the process-wide buddy service, creating it on first use.
|
|
||||||
|
|
||||||
`notmuch_service` only applies to the first caller (the bar that owns the
|
|
||||||
notmuch widget). One arbitrated mood and one re-arbitration ticker are
|
|
||||||
shared by every output instead of one per bar.
|
|
||||||
"""
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = BuddyService(notmuch_service=notmuch_service)
|
|
||||||
return _service
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Screen-capture IPC subscriber.
|
|
||||||
|
|
||||||
Listens to fenster's `:capture` event, which is backed by riverfx's
|
|
||||||
`capture_sessions` events (river-window-management-v1 v5+). Fenster sends the
|
|
||||||
full aggregate on every change and replays it on subscribe:
|
|
||||||
|
|
||||||
{
|
|
||||||
"change": "active" | "inactive",
|
|
||||||
"outputs": [{"name": "DP-1", "sessions": 1}],
|
|
||||||
"windows": [{"id": 42, "app_id": "firefox", "title": "...", "sessions": 1}]
|
|
||||||
}
|
|
||||||
|
|
||||||
Create the singleton before the GLib main loop starts so the replayed initial
|
|
||||||
state is not missed.
|
|
||||||
"""
|
|
||||||
from fabric.core.service import Service, Signal
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from sims.services.fenster import get_i3_connection
|
|
||||||
|
|
||||||
_service: "CaptureService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureService(Service):
|
|
||||||
@Signal
|
|
||||||
def capture_changed(self) -> None:
|
|
||||||
"""Emitted when the set of captured outputs/windows changes."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._outputs: list[dict] = []
|
|
||||||
self._windows: list[dict] = []
|
|
||||||
i3 = get_i3_connection()
|
|
||||||
i3.connect("event::capture::active", self._on_event)
|
|
||||||
i3.connect("event::capture::inactive", self._on_event)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def active(self) -> bool:
|
|
||||||
return bool(self._outputs or self._windows)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outputs(self) -> list[dict]:
|
|
||||||
"""Captured outputs: [{"name": str, "sessions": int}]."""
|
|
||||||
return list(self._outputs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def windows(self) -> list[dict]:
|
|
||||||
"""Captured windows: [{"id", "app_id", "title", "sessions"}]."""
|
|
||||||
return list(self._windows)
|
|
||||||
|
|
||||||
def _on_event(self, _i3, event):
|
|
||||||
outputs = event.data.get("outputs")
|
|
||||||
windows = event.data.get("windows")
|
|
||||||
if not isinstance(outputs, list) or not isinstance(windows, list):
|
|
||||||
logger.warning(f"[Capture] unexpected event payload: {event.data!r}")
|
|
||||||
return
|
|
||||||
if outputs == self._outputs and windows == self._windows:
|
|
||||||
return
|
|
||||||
self._outputs = outputs
|
|
||||||
self._windows = windows
|
|
||||||
self.capture_changed()
|
|
||||||
|
|
||||||
|
|
||||||
def get_capture_service() -> CaptureService:
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = CaptureService()
|
|
||||||
return _service
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""darkman control service.
|
|
||||||
|
|
||||||
Talks to darkman's control socket (`$XDG_RUNTIME_DIR/darkman/control.sock`,
|
|
||||||
a newline-delimited text protocol: `get`, `set light|dark`, `toggle`,
|
|
||||||
`watch`) so sims can show and switch the current light/dark mode.
|
|
||||||
|
|
||||||
A long-lived `watch` connection tracks the mode; darkman prints the current
|
|
||||||
mode immediately and then every change. Switching opens a short-lived
|
|
||||||
connection and sends `set <mode>`. If darkman is not running (yet), or
|
|
||||||
restarts, the watch connection is retried in the background.
|
|
||||||
|
|
||||||
This only *controls* darkman. Re-theming sims itself still happens through
|
|
||||||
the portal color-scheme (see `sims.services.theme`), which darkman updates
|
|
||||||
as part of every switch.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from fabric.core.service import Service, Signal
|
|
||||||
from gi.repository import Gio, GLib
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from sims.config import DARKMAN
|
|
||||||
|
|
||||||
MODES = ("light", "dark")
|
|
||||||
|
|
||||||
_RETRY_MIN_S = 2
|
|
||||||
_RETRY_MAX_S = 30
|
|
||||||
|
|
||||||
_service: "DarkmanService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def _default_socket_path() -> str:
|
|
||||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
|
||||||
return os.path.join(runtime_dir, "darkman", "control.sock")
|
|
||||||
|
|
||||||
|
|
||||||
class DarkmanService(Service):
|
|
||||||
@Signal
|
|
||||||
def changed(self, mode: object) -> None:
|
|
||||||
"""Emitted with the new mode ("light", "dark"), or None while darkman
|
|
||||||
is unreachable."""
|
|
||||||
|
|
||||||
def __init__(self, socket_path: str | None = None, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._socket_path = os.path.expandvars(
|
|
||||||
os.path.expanduser(socket_path or _default_socket_path())
|
|
||||||
)
|
|
||||||
self._mode: str | None = None
|
|
||||||
self._client = Gio.SocketClient.new()
|
|
||||||
self._watch_conn: Gio.SocketConnection | None = None
|
|
||||||
self._watch_stream: Gio.DataInputStream | None = None
|
|
||||||
self._retry_s = _RETRY_MIN_S
|
|
||||||
self._retry_source: int | None = None
|
|
||||||
self._connect_watch()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mode(self) -> str | None:
|
|
||||||
return self._mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
return self._mode is not None
|
|
||||||
|
|
||||||
# -- control ---------------------------------------------------------
|
|
||||||
|
|
||||||
def set_mode(self, mode: str) -> None:
|
|
||||||
if mode not in MODES:
|
|
||||||
raise ValueError(f"invalid darkman mode: {mode!r}")
|
|
||||||
self._send(f"set {mode}")
|
|
||||||
|
|
||||||
def toggle(self) -> None:
|
|
||||||
if self._mode is None:
|
|
||||||
logger.warning("[Darkman] not connected, cannot toggle mode")
|
|
||||||
return
|
|
||||||
self.set_mode("light" if self._mode == "dark" else "dark")
|
|
||||||
|
|
||||||
def _send(self, command: str) -> None:
|
|
||||||
self._client.connect_async(
|
|
||||||
Gio.UnixSocketAddress.new(self._socket_path),
|
|
||||||
None,
|
|
||||||
self._on_command_connected,
|
|
||||||
command,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_command_connected(self, client: Gio.SocketClient, result, command: str):
|
|
||||||
try:
|
|
||||||
conn = client.connect_finish(result)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.warning(f"[Darkman] could not send {command!r}: {e.message}")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# `set` gets no reply; a few bytes into a local socket never block.
|
|
||||||
conn.get_output_stream().write_all(f"{command}\n".encode(), None)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.warning(f"[Darkman] could not send {command!r}: {e.message}")
|
|
||||||
finally:
|
|
||||||
conn.close(None)
|
|
||||||
|
|
||||||
# -- watch -----------------------------------------------------------
|
|
||||||
|
|
||||||
def _connect_watch(self) -> bool:
|
|
||||||
self._retry_source = None
|
|
||||||
self._client.connect_async(
|
|
||||||
Gio.UnixSocketAddress.new(self._socket_path),
|
|
||||||
None,
|
|
||||||
self._on_watch_connected,
|
|
||||||
)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
def _on_watch_connected(self, client: Gio.SocketClient, result):
|
|
||||||
try:
|
|
||||||
conn = client.connect_finish(result)
|
|
||||||
conn.get_output_stream().write_all(b"watch\n", None)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.debug(f"[Darkman] socket {self._socket_path} unavailable: {e.message}")
|
|
||||||
self._schedule_retry()
|
|
||||||
return
|
|
||||||
logger.info(f"[Darkman] watching {self._socket_path}")
|
|
||||||
self._retry_s = _RETRY_MIN_S
|
|
||||||
self._watch_conn = conn
|
|
||||||
self._watch_stream = Gio.DataInputStream.new(conn.get_input_stream())
|
|
||||||
self._read_next()
|
|
||||||
|
|
||||||
def _read_next(self):
|
|
||||||
assert self._watch_stream is not None
|
|
||||||
self._watch_stream.read_line_async(
|
|
||||||
GLib.PRIORITY_DEFAULT, None, self._on_line
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_line(self, stream: Gio.DataInputStream, result):
|
|
||||||
try:
|
|
||||||
line, _length = stream.read_line_finish_utf8(result)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.warning(f"[Darkman] watch connection failed: {e.message}")
|
|
||||||
line = None
|
|
||||||
if line is None:
|
|
||||||
# EOF: darkman stopped or restarted.
|
|
||||||
self._drop_watch()
|
|
||||||
self._schedule_retry()
|
|
||||||
return
|
|
||||||
mode = line.strip()
|
|
||||||
self._set_mode(mode if mode in MODES else None)
|
|
||||||
self._read_next()
|
|
||||||
|
|
||||||
def _drop_watch(self):
|
|
||||||
if self._watch_conn is not None:
|
|
||||||
self._watch_conn.close(None)
|
|
||||||
self._watch_conn = None
|
|
||||||
self._watch_stream = None
|
|
||||||
self._set_mode(None)
|
|
||||||
|
|
||||||
def _schedule_retry(self):
|
|
||||||
if self._retry_source is not None:
|
|
||||||
return
|
|
||||||
self._retry_source = GLib.timeout_add_seconds(self._retry_s, self._connect_watch)
|
|
||||||
self._retry_s = min(self._retry_s * 2, _RETRY_MAX_S)
|
|
||||||
|
|
||||||
def _set_mode(self, mode: str | None):
|
|
||||||
if mode == self._mode:
|
|
||||||
return
|
|
||||||
logger.info(f"[Darkman] mode={mode}")
|
|
||||||
self._mode = mode
|
|
||||||
self.changed(mode)
|
|
||||||
|
|
||||||
|
|
||||||
def get_darkman_service() -> DarkmanService:
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = DarkmanService(socket_path=DARKMAN.get("socket_path"))
|
|
||||||
return _service
|
|
||||||
@@ -71,7 +71,6 @@ class I3MessageType(IntEnum):
|
|||||||
INPUT_EVENT = 0x80000015
|
INPUT_EVENT = 0x80000015
|
||||||
# fenster extensions (event id 100+)
|
# fenster extensions (event id 100+)
|
||||||
SMART_CORNERS_EVENT = 0x80000064
|
SMART_CORNERS_EVENT = 0x80000064
|
||||||
CAPTURE_EVENT = 0x80000065
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -116,24 +116,6 @@ class MprisPlayer(Service):
|
|||||||
GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False))
|
GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False))
|
||||||
# else do nothing
|
# else do nothing
|
||||||
|
|
||||||
def set_shuffle(self, do_shuffle: bool) -> None:
|
|
||||||
"""Like assigning `shuffle`, but lets DBus errors propagate (GObject
|
|
||||||
property setters swallow them), so callers can tell when a player
|
|
||||||
doesn't support shuffling."""
|
|
||||||
self._player.set_shuffle(do_shuffle)
|
|
||||||
self.notifier("shuffle")
|
|
||||||
|
|
||||||
def set_loop(self, status: str) -> None:
|
|
||||||
"""Like assigning `loop_status` ("none" / "track" / "playlist"), but
|
|
||||||
lets DBus errors propagate."""
|
|
||||||
loop_status = {
|
|
||||||
"none": Playerctl.LoopStatus.NONE,
|
|
||||||
"track": Playerctl.LoopStatus.TRACK,
|
|
||||||
"playlist": Playerctl.LoopStatus.PLAYLIST,
|
|
||||||
}[status]
|
|
||||||
self._player.set_loop_status(loop_status)
|
|
||||||
self.notifier("loop-status")
|
|
||||||
|
|
||||||
def play_pause(self):
|
def play_pause(self):
|
||||||
if self.can_pause:
|
if self.can_pause:
|
||||||
GLib.idle_add(lambda: (self._player.play_pause(), False))
|
GLib.idle_add(lambda: (self._player.play_pause(), False))
|
||||||
@@ -305,19 +287,3 @@ class MprisPlayerManager(Service):
|
|||||||
@Property(object, "readable")
|
@Property(object, "readable")
|
||||||
def players(self):
|
def players(self):
|
||||||
return self._manager.get_property("players") # type: ignore
|
return self._manager.get_property("players") # type: ignore
|
||||||
|
|
||||||
|
|
||||||
_service: "MprisPlayerManager | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_mpris_player_manager() -> "MprisPlayerManager":
|
|
||||||
"""Return the process-wide MPRIS manager, creating it on first use.
|
|
||||||
|
|
||||||
`Playerctl.PlayerManager.new()` plus one `Playerctl.Player` per MPRIS name
|
|
||||||
is the expensive part; every widget that wraps players should share this
|
|
||||||
manager and build its own `MprisPlayer` wrappers on top of it.
|
|
||||||
"""
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = MprisPlayerManager()
|
|
||||||
return _service
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
"""Org-mode agenda service.
|
|
||||||
|
|
||||||
Parses configured org files via `orgparse`, exposes a flat list of TODO-state
|
|
||||||
items grouped by state. Polls on an interval and re-reads files whose mtime
|
|
||||||
changed since the last sweep.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from glob import glob
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from fabric.utils import invoke_repeater
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
try:
|
|
||||||
import orgparse
|
|
||||||
ORGPARSE_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
ORGPARSE_AVAILABLE = False
|
|
||||||
logger.warning("[Org] orgparse not available — org widget disabled at runtime")
|
|
||||||
|
|
||||||
from sims.config import ORG
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OrgItem:
|
|
||||||
state: str
|
|
||||||
headline: str
|
|
||||||
file: str
|
|
||||||
line: int
|
|
||||||
tags: tuple[str, ...] = ()
|
|
||||||
scheduled: str | None = None
|
|
||||||
deadline: str | None = None
|
|
||||||
priority: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OrgSnapshot:
|
|
||||||
items: list[OrgItem] = field(default_factory=list)
|
|
||||||
counts: dict[str, int] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def total_counted(self, counted_states: list[str]) -> int:
|
|
||||||
return sum(self.counts.get(s, 0) for s in counted_states)
|
|
||||||
|
|
||||||
|
|
||||||
class OrgService:
|
|
||||||
def __init__(self, update_interval: int = 60_000):
|
|
||||||
self._update_interval = update_interval
|
|
||||||
self._timer_id = None
|
|
||||||
self._mtimes: dict[str, float] = {}
|
|
||||||
self._snapshot = OrgSnapshot()
|
|
||||||
self._callbacks: list = []
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
self._start_monitoring()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def snapshot(self) -> OrgSnapshot:
|
|
||||||
return self._snapshot
|
|
||||||
|
|
||||||
def connect(self, signal_name: str, callback) -> None:
|
|
||||||
if signal_name == "items-changed":
|
|
||||||
self._callbacks.append(callback)
|
|
||||||
|
|
||||||
def _emit(self, snapshot: OrgSnapshot) -> None:
|
|
||||||
for cb in self._callbacks:
|
|
||||||
cb(self, snapshot)
|
|
||||||
|
|
||||||
def _start_monitoring(self):
|
|
||||||
if self._timer_id is None:
|
|
||||||
self._timer_id = invoke_repeater(self._update_interval, self._tick)
|
|
||||||
logger.info(
|
|
||||||
f"[Org] Started periodic updates every {self._update_interval/1000:.0f}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _tick(self):
|
|
||||||
self.update()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _expand_paths(self) -> list[Path]:
|
|
||||||
raw = ORG.get("paths", []) or []
|
|
||||||
out: list[Path] = []
|
|
||||||
for entry in raw:
|
|
||||||
entry = os.path.expanduser(entry)
|
|
||||||
p = Path(entry)
|
|
||||||
if any(c in entry for c in "*?["):
|
|
||||||
for match in sorted(glob(entry, recursive=True)):
|
|
||||||
mp = Path(match)
|
|
||||||
if mp.is_file() and mp.suffix == ".org":
|
|
||||||
out.append(mp)
|
|
||||||
continue
|
|
||||||
if p.is_dir():
|
|
||||||
for match in sorted(p.rglob("*.org")):
|
|
||||||
out.append(match)
|
|
||||||
elif p.is_file():
|
|
||||||
out.append(p)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
if not ORG.get("enable", False):
|
|
||||||
return
|
|
||||||
if not ORGPARSE_AVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
files = self._expand_paths()
|
|
||||||
if not files:
|
|
||||||
if self._snapshot.items:
|
|
||||||
self._snapshot = OrgSnapshot()
|
|
||||||
self._emit(self._snapshot)
|
|
||||||
return
|
|
||||||
|
|
||||||
# mtime-based change detection — skip parse if nothing changed
|
|
||||||
new_mtimes: dict[str, float] = {}
|
|
||||||
for f in files:
|
|
||||||
try:
|
|
||||||
new_mtimes[str(f)] = f.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if new_mtimes == self._mtimes and self._snapshot.items:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._mtimes = new_mtimes
|
|
||||||
|
|
||||||
todo_keywords = list(ORG.get("todo_keywords", ["TODO", "NEXT", "IN-PROGRESS"]))
|
|
||||||
done_keywords = list(ORG.get("done_keywords", ["DONE", "CANCELLED"]))
|
|
||||||
|
|
||||||
items: list[OrgItem] = []
|
|
||||||
for f in files:
|
|
||||||
try:
|
|
||||||
items.extend(_parse_file(f, todo_keywords, done_keywords))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Org] Failed to parse {f}: {e}")
|
|
||||||
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
for it in items:
|
|
||||||
counts[it.state] = counts.get(it.state, 0) + 1
|
|
||||||
|
|
||||||
self._snapshot = OrgSnapshot(items=items, counts=counts)
|
|
||||||
logger.info(f"[Org] Parsed {len(items)} items across {len(files)} files: {counts}")
|
|
||||||
self._emit(self._snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_file(
|
|
||||||
path: Path,
|
|
||||||
todo_keywords: list[str],
|
|
||||||
done_keywords: list[str],
|
|
||||||
) -> Iterable[OrgItem]:
|
|
||||||
env = orgparse.OrgEnv(
|
|
||||||
todos=todo_keywords,
|
|
||||||
dones=done_keywords,
|
|
||||||
filename=str(path),
|
|
||||||
)
|
|
||||||
root = orgparse.load(str(path), env=env)
|
|
||||||
for node in root[1:]:
|
|
||||||
state = node.todo
|
|
||||||
if not state or state in done_keywords:
|
|
||||||
continue
|
|
||||||
yield OrgItem(
|
|
||||||
state=state,
|
|
||||||
headline=node.heading or "(untitled)",
|
|
||||||
file=str(path),
|
|
||||||
line=node.linenumber,
|
|
||||||
tags=tuple(sorted(node.tags)),
|
|
||||||
scheduled=str(node.scheduled) if node.scheduled else None,
|
|
||||||
deadline=str(node.deadline) if node.deadline else None,
|
|
||||||
priority=node.priority,
|
|
||||||
)
|
|
||||||
@@ -64,19 +64,3 @@ class SystemStatsService(Service):
|
|||||||
def memory_percent(self):
|
def memory_percent(self):
|
||||||
"""Get current memory percentage"""
|
"""Get current memory percentage"""
|
||||||
return self._memory_percent / 100
|
return self._memory_percent / 100
|
||||||
|
|
||||||
|
|
||||||
_service: "SystemStatsService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_system_stats_service(update_interval: int = 3000) -> "SystemStatsService":
|
|
||||||
"""Return the process-wide stats service, creating it on first use.
|
|
||||||
|
|
||||||
`update_interval` only applies to the first caller: one poller feeds every
|
|
||||||
bar, and psutil's non-blocking `cpu_percent` keeps a process-global baseline
|
|
||||||
that extra pollers would keep resetting.
|
|
||||||
"""
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = SystemStatsService(update_interval=update_interval)
|
|
||||||
return _service
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Light/dark theme mode service.
|
|
||||||
|
|
||||||
Follows the XDG desktop portal's `org.freedesktop.appearance color-scheme`
|
|
||||||
setting (served by darkman, GNOME, KDE, ...) and emits `changed` whenever the
|
|
||||||
effective mode flips. The mode is one of:
|
|
||||||
|
|
||||||
- "dark" -- portal reports prefer-dark (1)
|
|
||||||
- "light" -- portal reports prefer-light (2)
|
|
||||||
- None -- no preference (0) or no portal available; callers fall back
|
|
||||||
to the base stylix palette.
|
|
||||||
|
|
||||||
The portal is only queried when `stylix.variants` is configured, so setups
|
|
||||||
without light/dark palettes never touch DBus here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fabric.core.service import Service, Signal
|
|
||||||
from gi.repository import Gio, GLib
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from sims.config import STYLIX
|
|
||||||
|
|
||||||
PORTAL_BUS = "org.freedesktop.portal.Desktop"
|
|
||||||
PORTAL_PATH = "/org/freedesktop/portal/desktop"
|
|
||||||
SETTINGS_IFACE = "org.freedesktop.portal.Settings"
|
|
||||||
APPEARANCE_NS = "org.freedesktop.appearance"
|
|
||||||
COLOR_SCHEME_KEY = "color-scheme"
|
|
||||||
|
|
||||||
_SCHEME_TO_MODE = {1: "dark", 2: "light"}
|
|
||||||
|
|
||||||
_service: "ThemeService | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def _unwrap(value):
|
|
||||||
"""Strip nested GLib.Variant layers (`Read` wraps the value twice)."""
|
|
||||||
while isinstance(value, GLib.Variant):
|
|
||||||
value = value.unpack()
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ThemeService(Service):
|
|
||||||
@Signal
|
|
||||||
def changed(self, mode: object) -> None:
|
|
||||||
"""Emitted with the new mode ("light", "dark" or None)."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._mode: str | None = None
|
|
||||||
self._bus: Gio.DBusConnection | None = None
|
|
||||||
|
|
||||||
if not STYLIX.get("enable", False) or not STYLIX.get("variants"):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.warning(f"[Theme] no session bus, not following color-scheme: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._bus.signal_subscribe(
|
|
||||||
PORTAL_BUS,
|
|
||||||
SETTINGS_IFACE,
|
|
||||||
"SettingChanged",
|
|
||||||
PORTAL_PATH,
|
|
||||||
None,
|
|
||||||
Gio.DBusSignalFlags.NONE,
|
|
||||||
self._on_setting_changed,
|
|
||||||
)
|
|
||||||
# Async so a slow portal activation at session start can't stall the
|
|
||||||
# bar; the palette is swapped in as soon as the reply lands.
|
|
||||||
self._read("ReadOne")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mode(self) -> str | None:
|
|
||||||
return self._mode
|
|
||||||
|
|
||||||
def _read(self, method: str):
|
|
||||||
assert self._bus is not None
|
|
||||||
self._bus.call(
|
|
||||||
PORTAL_BUS,
|
|
||||||
PORTAL_PATH,
|
|
||||||
SETTINGS_IFACE,
|
|
||||||
method,
|
|
||||||
GLib.Variant("(ss)", (APPEARANCE_NS, COLOR_SCHEME_KEY)),
|
|
||||||
None,
|
|
||||||
Gio.DBusCallFlags.NONE,
|
|
||||||
-1,
|
|
||||||
None,
|
|
||||||
self._on_read,
|
|
||||||
method,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_read(self, bus: Gio.DBusConnection, result: Gio.AsyncResult, method: str):
|
|
||||||
try:
|
|
||||||
reply = bus.call_finish(result)
|
|
||||||
except GLib.Error as e:
|
|
||||||
if method == "ReadOne":
|
|
||||||
# Settings portal < v2 only has the (deprecated) Read.
|
|
||||||
self._read("Read")
|
|
||||||
return
|
|
||||||
logger.warning(f"[Theme] could not read color-scheme from portal: {e.message}")
|
|
||||||
return
|
|
||||||
self._set_scheme(_unwrap(reply.get_child_value(0)))
|
|
||||||
|
|
||||||
def _on_setting_changed(self, _bus, _sender, _path, _iface, _signal, params: GLib.Variant):
|
|
||||||
namespace, key, value = params.unpack()
|
|
||||||
if namespace == APPEARANCE_NS and key == COLOR_SCHEME_KEY:
|
|
||||||
self._set_scheme(_unwrap(value))
|
|
||||||
|
|
||||||
def _set_scheme(self, scheme):
|
|
||||||
mode = _SCHEME_TO_MODE.get(scheme) if isinstance(scheme, int) else None
|
|
||||||
if mode == self._mode:
|
|
||||||
return
|
|
||||||
logger.info(f"[Theme] color-scheme={scheme!r} -> mode={mode}")
|
|
||||||
self._mode = mode
|
|
||||||
self.changed(mode)
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme_service() -> ThemeService:
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = ThemeService()
|
|
||||||
return _service
|
|
||||||
+88
-185
@@ -1,18 +1,8 @@
|
|||||||
/* ------------------------------------------------------------------------
|
|
||||||
Bar shell
|
|
||||||
|
|
||||||
One visual language for every module:
|
|
||||||
- "chips": --surface fill, same height (bar height minus the 6px
|
|
||||||
vertical padding below), fully rounded, 10px side padding.
|
|
||||||
- a single accent color for "this is active" (workspace, play state),
|
|
||||||
tinted chips for "needs attention", solid fill only for alarms.
|
|
||||||
- text is foreground / --text-muted, icons are 16px.
|
|
||||||
------------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
#sims-inner {
|
#sims-inner {
|
||||||
padding: 6px 12px;
|
padding: 4px;
|
||||||
|
border-bottom: solid 2px;
|
||||||
|
border-color: var(--border-color);
|
||||||
background-color: var(--window-bg);
|
background-color: var(--window-bg);
|
||||||
border-bottom: solid 1px var(--hairline);
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
transition: border-radius 200ms ease;
|
transition: border-radius 200ms ease;
|
||||||
}
|
}
|
||||||
@@ -21,207 +11,120 @@
|
|||||||
border-radius: 0 0 28px 28px;
|
border-radius: 0 0 28px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared chip look (workspaces.css applies the same to #workspaces) */
|
#center-container {
|
||||||
#player-small,
|
color: var(--foreground);
|
||||||
#widgets-container,
|
|
||||||
#org-widget,
|
|
||||||
#notmuch-widget,
|
|
||||||
#date-time {
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: var(--surface);
|
|
||||||
transition: background-color 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#org-widget:hover,
|
|
||||||
#notmuch-widget:hover,
|
|
||||||
#date-time-button:hover #date-time {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- left ------------------------------------------------------------- */
|
|
||||||
|
|
||||||
#nixos-label {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 0 6px 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buddy-container,
|
|
||||||
#buddy {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buddy-button {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compact player chip: circular cover hugging the left edge */
|
|
||||||
#player-small {
|
|
||||||
padding: 0 6px 0 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#compact-mpris-icon,
|
|
||||||
#compact-mpris-button {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#compact-mpris-label {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#compact-mpris-fallback {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: 0 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#compact-mpris-button-icon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#compact-mpris-button:hover #compact-mpris-button-icon {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- center ----------------------------------------------------------- */
|
|
||||||
|
|
||||||
#active-window,
|
|
||||||
.active-window {
|
.active-window {
|
||||||
color: var(--text-muted);
|
color: var(--foreground);
|
||||||
font-weight: 500;
|
font-weight: bold;
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- right ------------------------------------------------------------ */
|
|
||||||
|
|
||||||
/* System cluster: mic, cpu/ram rings, tray, battery in one chip */
|
|
||||||
#widgets-container {
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#microphone-button {
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#microphone-icon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#microphone-button:hover #microphone-icon {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
#microphone-icon.muted {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CPU / RAM "activity rings": outer ring = RAM, inner ring = CPU.
|
|
||||||
CircularProgressBar draws its track with `color` and progress with
|
|
||||||
`border-color`; the border *width* must stay 0 or it inflates the line. */
|
|
||||||
#system-stats {
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ram-progress-bar,
|
|
||||||
#cpu-progress-bar {
|
|
||||||
background-color: transparent;
|
|
||||||
color: alpha(var(--foreground), 0.12);
|
|
||||||
border: solid 0 transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ram-progress-bar {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-progress-bar {
|
|
||||||
border-color: var(--violet);
|
|
||||||
}
|
|
||||||
|
|
||||||
#system-tray {
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#system-tray > button {
|
|
||||||
padding: 0 3px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#system-tray > button:hover {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#battery-widget {
|
#battery-widget {
|
||||||
padding: 0 2px 0 4px;
|
background-color: var(--module-bg);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bat-icon {
|
#bat-icon {
|
||||||
color: var(--text-muted);
|
color: var(--blue);
|
||||||
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bat-label {
|
#bat-label {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-feature-settings: "tnum";
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bat-icon.battery-low,
|
|
||||||
#bat-label.battery-low {
|
#bat-label.battery-low {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Screen-share indicator: a bare red dot, only visible while sharing */
|
#cpu-progress-bar,
|
||||||
#capture-widget {
|
#ram-progress-bar,
|
||||||
|
#volume-progress-bar {
|
||||||
|
color: transparent;
|
||||||
|
background-color: transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
#cpu-progress-bar {
|
||||||
|
border: solid 0px alpha(var(--violet), 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ram-progress-bar,
|
||||||
|
#volume-progress-bar {
|
||||||
|
border: solid 0px var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#widgets-container {
|
||||||
|
background-color: var(--module-bg);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-small {
|
||||||
|
background-color: var(--module-bg);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#compact-mpris-icon,
|
||||||
|
#compact-mpris-button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#compact-mpris-button-icon {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
#compact-mpris-button:hover #compact-mpris-button-icon {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#compact-mpris-fallback {
|
||||||
|
color: var(--foreground);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#compact-mpris-label {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nixos-label {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenrec-widget {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#capture-dot {
|
#screenrec-dot {
|
||||||
color: var(--red);
|
color: #ff4444;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenrec-elapsed {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
#capture-summary {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clock: bold time, muted date */
|
|
||||||
#date-time-button {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#date-time {
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clock-time {
|
|
||||||
font-weight: bold;
|
|
||||||
font-feature-settings: "tnum";
|
|
||||||
}
|
|
||||||
|
|
||||||
#clock-date {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-feature-settings: "tnum";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- tooltips --------------------------------------------------------- */
|
|
||||||
|
|
||||||
tooltip {
|
tooltip {
|
||||||
|
border: solid 2px;
|
||||||
|
border-color: var(--border-color);
|
||||||
background-color: var(--window-bg);
|
background-color: var(--window-bg);
|
||||||
border: solid 1px var(--hairline);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltip > * {
|
tooltip>* {
|
||||||
padding: 4px 8px;
|
padding: 2px 4px
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
/* Calendar widget styling */
|
/* Calendar widget styling */
|
||||||
|
|
||||||
|
/* Date time button */
|
||||||
|
#date-time-button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#date-time {
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: var(--module-bg);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Calendar event items */
|
/* Calendar event items */
|
||||||
.event-item {
|
.event-item {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
+6
-17
@@ -17,24 +17,13 @@
|
|||||||
--violet: #AD60FF;
|
--violet: #AD60FF;
|
||||||
--red: #FC3F2C;
|
--red: #FC3F2C;
|
||||||
|
|
||||||
/* Semantic tokens. Surfaces/text are derived from --foreground so they
|
--window-bg: alpha(var(--background), 0.9);
|
||||||
work on both dark and light palettes (stylix redefines the base
|
|
||||||
colors at runtime and these follow). */
|
|
||||||
--accent: var(--blue);
|
|
||||||
--surface: alpha(var(--foreground), 0.06);
|
|
||||||
--surface-hover: alpha(var(--foreground), 0.11);
|
|
||||||
--surface-active: alpha(var(--foreground), 0.16);
|
|
||||||
--hairline: alpha(var(--foreground), 0.08);
|
|
||||||
--text-muted: alpha(var(--foreground), 0.62);
|
|
||||||
--text-faint: alpha(var(--foreground), 0.38);
|
|
||||||
|
|
||||||
--window-bg: alpha(var(--background), 0.94);
|
|
||||||
--module-bg: alpha(var(--mid-bg), 0.8);
|
--module-bg: alpha(var(--mid-bg), 0.8);
|
||||||
--border-color: var(--light-bg);
|
--border-color: var(--light-bg);
|
||||||
--ws-active: var(--accent);
|
--ws-active: var(--blue);
|
||||||
--ws-visible: alpha(var(--accent), 0.5);
|
--ws-visible: var(--violet);
|
||||||
--ws-inactive: alpha(var(--foreground), 0.55);
|
--ws-inactive: var(--light-grey);
|
||||||
--ws-empty: alpha(var(--foreground), 0.18);
|
--ws-empty: var(--dark-grey);
|
||||||
--ws-hover: alpha(var(--foreground), 0.85);
|
--ws-hover: var(--turquoise);
|
||||||
--ws-urgent: var(--red);
|
--ws-urgent: var(--red);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
#notmuch-date {
|
#notmuch-date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
min-width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-saved-name {
|
#notmuch-saved-name {
|
||||||
|
|||||||
@@ -7,20 +7,12 @@
|
|||||||
@import url("./calendar.css");
|
@import url("./calendar.css");
|
||||||
@import url("./notmuch.css");
|
@import url("./notmuch.css");
|
||||||
@import url("./notifications.css");
|
@import url("./notifications.css");
|
||||||
@import url("./org.css");
|
|
||||||
@import url("./media.css");
|
|
||||||
|
|
||||||
|
|
||||||
/* unset so we can style everything from the ground up. `all: unset`
|
/* unset so we can style everything from the ground up. */
|
||||||
makes font-size/font-family inherit, so the base font is set once on the
|
|
||||||
toplevels and any widget rule (e.g. icon sizes) can still override it. */
|
|
||||||
* {
|
* {
|
||||||
all: unset;
|
all: unset;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
|
||||||
|
|
||||||
window,
|
|
||||||
tooltip {
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-family: "Jost*", sans-serif;
|
font-family: "Jost*", sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
/* Media hover card (drops down from the bar's compact player) */
|
|
||||||
|
|
||||||
#media-card {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-body {
|
|
||||||
background-color: var(--window-bg);
|
|
||||||
border: solid 2px var(--border-color);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 16px 18px 14px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-cover-fallback {
|
|
||||||
color: var(--light-grey);
|
|
||||||
background-color: var(--module-bg);
|
|
||||||
border-radius: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-player {
|
|
||||||
color: var(--light-grey);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-title {
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-artist {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--mid-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-album {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--light-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Seek bar */
|
|
||||||
#media-card-progress {
|
|
||||||
min-height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-progress trough {
|
|
||||||
min-height: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-progress highlight {
|
|
||||||
min-height: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-progress slider {
|
|
||||||
min-width: 12px;
|
|
||||||
min-height: 12px;
|
|
||||||
margin: -4px;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-progress:disabled highlight {
|
|
||||||
background-color: var(--dark-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-time {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--light-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transport controls */
|
|
||||||
#media-card-btn,
|
|
||||||
#media-card-toggle {
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 100px;
|
|
||||||
transition: background-color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-btn:hover,
|
|
||||||
#media-card-toggle:hover {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* `* { color }` in main.css sets every widget's color directly, so icons
|
|
||||||
have to be targeted themselves rather than inheriting from the button. */
|
|
||||||
#media-card-toggle image {
|
|
||||||
color: var(--light-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-toggle.active image {
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-btn:disabled,
|
|
||||||
#media-card-toggle:disabled,
|
|
||||||
#media-card-play:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-play {
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: var(--blue);
|
|
||||||
transition: background-color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-play image {
|
|
||||||
color: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-play:hover {
|
|
||||||
background-color: var(--turquoise);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Player switcher (only shown with more than one player) */
|
|
||||||
#media-card-player-chip {
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: var(--module-bg);
|
|
||||||
transition: background-color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-player-chip label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--light-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-player-chip:hover {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-player-chip.active {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#media-card-player-chip.active label {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
+27
-19
@@ -1,30 +1,38 @@
|
|||||||
/* Context menus (system tray) */
|
#date-time,
|
||||||
|
menu>menuitem>label,
|
||||||
|
#date-time>label,
|
||||||
|
/* system tray */
|
||||||
|
#system-tray {
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: var(--module-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* menu and menu items (written for the system tray) */
|
||||||
menu {
|
menu {
|
||||||
padding: 4px;
|
border: solid 2px;
|
||||||
border: solid 1px var(--hairline);
|
border-radius: 10px;
|
||||||
border-radius: 12px;
|
border-color: var(--border-color);
|
||||||
background-color: var(--window-bg);
|
background-color: var(--window-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
menu > menuitem {
|
menu>menuitem {
|
||||||
padding: 6px 10px;
|
border-radius: 0px;
|
||||||
border-radius: 8px;
|
background-color: var(--module-bg);
|
||||||
|
padding: 6px;
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu > menuitem:hover {
|
menu>menuitem:first-child {
|
||||||
background-color: alpha(var(--accent), 0.18);
|
margin-top: 1px;
|
||||||
|
border-radius: 8px 8px 0px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu > menuitem:hover > label {
|
menu>menuitem:last-child {
|
||||||
color: var(--accent);
|
margin-bottom: 1px;
|
||||||
|
border-radius: 0px 0px 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu > menuitem:disabled > label {
|
menu>menuitem:hover {
|
||||||
color: var(--text-faint);
|
background-color: var(--pink);
|
||||||
}
|
|
||||||
|
|
||||||
menu > separator {
|
|
||||||
min-height: 1px;
|
|
||||||
margin: 4px 6px;
|
|
||||||
background-color: var(--hairline);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-25
@@ -1,42 +1,34 @@
|
|||||||
/* Notmuch unread badge. Base chip look lives in bar.css.
|
/* Notmuch email widget styling */
|
||||||
Calm by default; tinted when there is unread mail, solid red only when
|
|
||||||
the email debt timer is in alarm. */
|
|
||||||
|
|
||||||
#notmuch-icon {
|
#notmuch-widget {
|
||||||
color: var(--text-muted);
|
background-color: var(--module-bg);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#unread-count {
|
#notmuch-widget:hover {
|
||||||
color: var(--foreground);
|
background-color: var(--light-bg);
|
||||||
font-weight: bold;
|
|
||||||
font-feature-settings: "tnum";
|
|
||||||
min-width: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.has-unread {
|
#notmuch-widget.has-unread {
|
||||||
background-color: alpha(var(--accent), 0.18);
|
background-color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.has-unread:hover {
|
#notmuch-widget.has-unread:hover {
|
||||||
background-color: alpha(var(--accent), 0.28);
|
background-color: var(--turquoise);
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.has-unread #notmuch-icon,
|
#notmuch-widget.no-unread {
|
||||||
#notmuch-widget.has-unread #unread-count {
|
background-color: var(--module-bg);
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.debt-warn {
|
#notmuch-widget.debt-warn {
|
||||||
background-color: alpha(var(--orange), 0.2);
|
background-color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.debt-warn:hover {
|
#notmuch-widget.debt-warn:hover {
|
||||||
background-color: alpha(var(--orange), 0.3);
|
background-color: var(--gold);
|
||||||
}
|
|
||||||
|
|
||||||
#notmuch-widget.debt-warn #notmuch-icon,
|
|
||||||
#notmuch-widget.debt-warn #unread-count {
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.debt-alarm {
|
#notmuch-widget.debt-alarm {
|
||||||
@@ -44,10 +36,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.debt-alarm:hover {
|
#notmuch-widget.debt-alarm:hover {
|
||||||
background-color: shade(var(--red), 1.1);
|
background-color: var(--pink);
|
||||||
}
|
}
|
||||||
|
|
||||||
#notmuch-widget.debt-alarm #notmuch-icon,
|
#unread-count {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notmuch-widget.has-unread #unread-count,
|
||||||
|
#notmuch-widget.debt-warn #unread-count,
|
||||||
#notmuch-widget.debt-alarm #unread-count {
|
#notmuch-widget.debt-alarm #unread-count {
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
}
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
/* Org-agenda bar widget + dropdown. Base chip look lives in bar.css. */
|
|
||||||
|
|
||||||
#org-icon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-count {
|
|
||||||
font-weight: bold;
|
|
||||||
font-feature-settings: "tnum";
|
|
||||||
min-width: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.empty {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.has-next {
|
|
||||||
background-color: alpha(var(--accent), 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.has-next:hover {
|
|
||||||
background-color: alpha(var(--accent), 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.has-next #org-icon,
|
|
||||||
#org-widget.has-next #org-count {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.has-in-progress {
|
|
||||||
background-color: alpha(var(--violet), 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.has-in-progress:hover {
|
|
||||||
background-color: alpha(var(--violet), 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-widget.has-in-progress #org-icon,
|
|
||||||
#org-widget.has-in-progress #org-count {
|
|
||||||
color: var(--violet);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown */
|
|
||||||
|
|
||||||
#org-dropdown {
|
|
||||||
background-color: var(--window-bg);
|
|
||||||
border-radius: 0 0 16px 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-body {
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-header {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-close {
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-close:hover {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-empty {
|
|
||||||
color: var(--light-grey);
|
|
||||||
font-style: italic;
|
|
||||||
padding: 12px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-section {
|
|
||||||
padding: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-dropdown-section-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-state-next #org-dropdown-section-title {
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-state-in-progress #org-dropdown-section-title {
|
|
||||||
color: var(--violet);
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-state-todo #org-dropdown-section-title {
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-state-count {
|
|
||||||
color: var(--light-grey);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-item {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#org-item:hover {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-item-title {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-item-meta {
|
|
||||||
color: var(--light-grey);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
@@ -27,35 +27,3 @@
|
|||||||
#vinyl-button.active #vinyl-icon {
|
#vinyl-button.active #vinyl-icon {
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme (darkman light/dark) toggle -- same look as the vinyl button */
|
|
||||||
#theme-toggle-button {
|
|
||||||
background-color: var(--module-bg);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle-button:hover {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle-button.active {
|
|
||||||
background-color: var(--pink);
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle-button.active:hover {
|
|
||||||
background-color: var(--turquoise);
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle-button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle-icon {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle-button.active #theme-toggle-icon {
|
|
||||||
color: var(--background);
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-28
@@ -1,54 +1,50 @@
|
|||||||
/* Workspace rail: small dots, the focused one stretches into an accent pill.
|
|
||||||
The chip is 28px tall; 10px vertical padding leaves 8px dots. */
|
|
||||||
#workspaces {
|
#workspaces {
|
||||||
min-height: 8px;
|
padding: 6px;
|
||||||
padding: 10px 12px;
|
min-width: 0px;
|
||||||
border-radius: 14px;
|
background-color: var(--module-bg);
|
||||||
background-color: var(--surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#workspaces > button {
|
#workspaces>button {
|
||||||
min-width: 8px;
|
padding: 8px;
|
||||||
min-height: 8px;
|
transition: padding 0.05s steps(8), background-color 0.15s ease;
|
||||||
border-radius: 99px;
|
|
||||||
background-color: var(--ws-inactive);
|
background-color: var(--ws-inactive);
|
||||||
transition: min-width 200ms cubic-bezier(0.4, 0, 0.2, 1),
|
border-radius: 100px;
|
||||||
background-color 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#workspaces > button > label {
|
#workspaces>button>label {
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#workspaces > button.empty {
|
#workspaces>button:hover {
|
||||||
background-color: var(--ws-empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
#workspaces > button.visible {
|
|
||||||
background-color: var(--ws-visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
#workspaces > button:hover {
|
|
||||||
background-color: var(--ws-hover);
|
background-color: var(--ws-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
#workspaces > button.active {
|
#workspaces>button.empty {
|
||||||
min-width: 24px;
|
background-color: var(--ws-empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces>button.visible {
|
||||||
|
background-color: var(--ws-visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces>button.active {
|
||||||
|
padding: 8px 32px;
|
||||||
background-color: var(--ws-active);
|
background-color: var(--ws-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
#workspaces > button.urgent {
|
#workspaces>button.urgent {
|
||||||
background-color: var(--ws-urgent);
|
background-color: var(--ws-urgent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: bold;
|
||||||
animation: urgent-blink 1s infinite;
|
animation: urgent-blink 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Workspace shown on a different monitor than this bar */
|
#workspaces>button.foreign {
|
||||||
#workspaces > button.foreign {
|
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes urgent-blink {
|
@keyframes urgent-blink {
|
||||||
0% { opacity: 1.0; }
|
0% { opacity: 1.0; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.5; }
|
||||||
100% { opacity: 1.0; }
|
100% { opacity: 1.0; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Resolve MPRIS `mpris:artUrl` values to local file paths.
|
|
||||||
|
|
||||||
Remote artwork (Spotify, browsers) is downloaded once per URL on a worker
|
|
||||||
thread and cached for the lifetime of the process, so every widget showing
|
|
||||||
the cover — the bar pill and the hover card — shares a single download
|
|
||||||
instead of re-fetching on every MPRIS `changed` emission.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from gi.repository import GLib
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
_CACHE_LIMIT = 32
|
|
||||||
_cache: dict[str, str | None] = {}
|
|
||||||
# url -> callbacks waiting for an in-flight download of that url
|
|
||||||
_pending: dict[str, list[Callable[[str | None], None]]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _remember(url: str, path: str | None) -> None:
|
|
||||||
_cache[url] = path
|
|
||||||
while len(_cache) > _CACHE_LIMIT:
|
|
||||||
old_url = next(iter(_cache))
|
|
||||||
old_path = _cache.pop(old_url)
|
|
||||||
if old_path and old_path.startswith(tempfile.gettempdir()):
|
|
||||||
try:
|
|
||||||
os.unlink(old_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _download(url: str) -> None:
|
|
||||||
path: str | None = None
|
|
||||||
try:
|
|
||||||
suffix = os.path.splitext(urllib.parse.urlparse(url).path)[1] or ".png"
|
|
||||||
with urllib.request.urlopen(url, timeout=10) as response:
|
|
||||||
data = response.read()
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
delete=False, prefix="sims-art-", suffix=suffix
|
|
||||||
) as tmp:
|
|
||||||
tmp.write(data)
|
|
||||||
path = tmp.name
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[Artwork] download failed for {url}: {e}")
|
|
||||||
|
|
||||||
def finish():
|
|
||||||
_remember(url, path)
|
|
||||||
for cb in _pending.pop(url, []):
|
|
||||||
cb(path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
GLib.idle_add(finish)
|
|
||||||
|
|
||||||
|
|
||||||
def file_key(path: str | None) -> tuple[str, int] | None:
|
|
||||||
"""`(path, mtime)` for an existing file, else None. Some players rewrite
|
|
||||||
their artwork in place at a fixed path, so widgets compare this rather
|
|
||||||
than the bare path to decide whether the cover needs reloading."""
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return (path, os.stat(path).st_mtime_ns)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_artwork(
|
|
||||||
arturl: str | None, callback: Callable[[str | None], None]
|
|
||||||
) -> None:
|
|
||||||
"""Call `callback(path_or_None)` on the main loop with a local file for
|
|
||||||
`arturl`. Local files resolve synchronously; http(s) URLs resolve after a
|
|
||||||
(cached) background download."""
|
|
||||||
if not arturl:
|
|
||||||
callback(None)
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urllib.parse.urlparse(arturl)
|
|
||||||
if parsed.scheme in ("http", "https"):
|
|
||||||
if arturl in _cache:
|
|
||||||
callback(_cache[arturl])
|
|
||||||
return
|
|
||||||
waiters = _pending.get(arturl)
|
|
||||||
if waiters is not None:
|
|
||||||
waiters.append(callback)
|
|
||||||
return
|
|
||||||
_pending[arturl] = [callback]
|
|
||||||
GLib.Thread.new("sims-artwork", _download, arturl)
|
|
||||||
return
|
|
||||||
|
|
||||||
path = urllib.parse.unquote(parsed.path) if parsed.scheme == "file" else arturl
|
|
||||||
callback(path if path and os.path.isfile(path) else None)
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
from gi.repository import GdkPixbuf, GLib
|
|
||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.image import Image
|
|
||||||
from fabric.widgets.button import Button
|
|
||||||
|
|
||||||
from sims.modules.stylix import resolve_colors
|
|
||||||
from sims.services.theme import get_theme_service
|
|
||||||
|
|
||||||
|
|
||||||
# 16x16 sprite grid, displayed at SCALE x scale (32x32) with crisp pixels.
|
|
||||||
SPRITE_SIZE = 16
|
|
||||||
SCALE = 2
|
|
||||||
|
|
||||||
# Palette character → semantic role. Resolved to RGBA from stylix at render time.
|
|
||||||
# . transparent
|
|
||||||
# B body main
|
|
||||||
# H body highlight (lighter)
|
|
||||||
# b body shadow (darker)
|
|
||||||
# e eye dark
|
|
||||||
# w eye sparkle / white
|
|
||||||
# m mouth / closed-eye line
|
|
||||||
# c blush
|
|
||||||
# s sweat drop
|
|
||||||
# z z text
|
|
||||||
# * sparkle / excitement
|
|
||||||
# _ semi-transparent ground shadow
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sprite construction: layered grid with strict 16x16 validation.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _grid(rows: list[str]) -> list[str]:
|
|
||||||
if len(rows) != SPRITE_SIZE:
|
|
||||||
raise ValueError(f"sprite must be {SPRITE_SIZE} rows, got {len(rows)}")
|
|
||||||
for i, r in enumerate(rows):
|
|
||||||
if len(r) != SPRITE_SIZE:
|
|
||||||
raise ValueError(f"row {i} has {len(r)} chars (want {SPRITE_SIZE}): {r!r}")
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _overlay(base: list[str], *layers: list[tuple[int, int, str]]) -> list[str]:
|
|
||||||
grid = [list(r) for r in base]
|
|
||||||
for layer in layers:
|
|
||||||
for (y, x, ch) in layer:
|
|
||||||
if 0 <= y < SPRITE_SIZE and 0 <= x < SPRITE_SIZE:
|
|
||||||
grid[y][x] = ch
|
|
||||||
return ["".join(r) for r in grid]
|
|
||||||
|
|
||||||
|
|
||||||
def _shift(base: list[str], dy: int = 0, dx: int = 0) -> list[str]:
|
|
||||||
out = []
|
|
||||||
for y in range(SPRITE_SIZE):
|
|
||||||
src_y = y - dy
|
|
||||||
if 0 <= src_y < SPRITE_SIZE:
|
|
||||||
row = base[src_y]
|
|
||||||
else:
|
|
||||||
row = "." * SPRITE_SIZE
|
|
||||||
if dx > 0:
|
|
||||||
row = "." * dx + row[:-dx]
|
|
||||||
elif dx < 0:
|
|
||||||
row = row[-dx:] + "." * (-dx)
|
|
||||||
out.append(row)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _shift_overlay(overlay: list[tuple[int, int, str]], dy: int = 0, dx: int = 0) -> list[tuple[int, int, str]]:
|
|
||||||
return [(y + dy, x + dx, ch) for (y, x, ch) in overlay]
|
|
||||||
|
|
||||||
|
|
||||||
# Body shapes ---------------------------------------------------------------
|
|
||||||
|
|
||||||
BODY_REST = _grid([
|
|
||||||
"................",
|
|
||||||
"................",
|
|
||||||
"................",
|
|
||||||
".......BB.......",
|
|
||||||
"......BBBB......",
|
|
||||||
".....BBBBBB.....",
|
|
||||||
"....HBBBBBBB....",
|
|
||||||
"...HBBBBBBBBB...",
|
|
||||||
"..HBBBBBBBBBBB..",
|
|
||||||
"..BBBBBBBBBBBB..",
|
|
||||||
".BBBBBBBBBBBBBb.",
|
|
||||||
".BBBBBBBBBBBBBb.",
|
|
||||||
".BBBBBBBBBBBBbb.",
|
|
||||||
"BBBBBBBBBBBBBbbb",
|
|
||||||
"BBBBBBBBBBBBBbbb",
|
|
||||||
"................",
|
|
||||||
])
|
|
||||||
|
|
||||||
BODY_BOB = _shift(BODY_REST, dy=-1) # bobbed up by 1 row
|
|
||||||
|
|
||||||
BODY_SQUISH = _grid([
|
|
||||||
"................",
|
|
||||||
"................",
|
|
||||||
"................",
|
|
||||||
"................",
|
|
||||||
"......BBBB......",
|
|
||||||
".....BBBBBB.....",
|
|
||||||
"....BBBBBBBB....",
|
|
||||||
"...HBBBBBBBBB...",
|
|
||||||
"..HBBBBBBBBBBB..",
|
|
||||||
"..BBBBBBBBBBBB..",
|
|
||||||
".BBBBBBBBBBBBBb.",
|
|
||||||
".BBBBBBBBBBBBBb.",
|
|
||||||
"BBBBBBBBBBBBBbbb",
|
|
||||||
"BBBBBBBBBBBBBbbb",
|
|
||||||
"BBBBBBBBBBBBBbbb",
|
|
||||||
"................",
|
|
||||||
])
|
|
||||||
|
|
||||||
BODY_LEAN_L = _shift(BODY_REST, dx=-1)
|
|
||||||
BODY_LEAN_R = _shift(BODY_REST, dx=1)
|
|
||||||
|
|
||||||
|
|
||||||
# Face overlays. Eye row defaults to row 8 (rest pose); pass r= for bob/squish.
|
|
||||||
|
|
||||||
def eyes_open(r: int = 8) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 5, "e"), (r, 10, "e")]
|
|
||||||
|
|
||||||
|
|
||||||
def eyes_blink(r: int = 8) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 4, "m"), (r, 5, "m"), (r, 9, "m"), (r, 10, "m")]
|
|
||||||
|
|
||||||
|
|
||||||
def eyes_closed_happy(r: int = 8) -> list[tuple[int, int, str]]:
|
|
||||||
# ^^ shaped happy eyes
|
|
||||||
return [(r - 1, 4, "m"), (r - 1, 9, "m"),
|
|
||||||
(r, 5, "m"), (r, 10, "m"),
|
|
||||||
(r, 3, "m"), (r, 8, "m")]
|
|
||||||
|
|
||||||
|
|
||||||
def eyes_wide(r: int = 8) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r - 1, 5, "w"), (r - 1, 10, "w"),
|
|
||||||
(r, 5, "e"), (r, 10, "e")]
|
|
||||||
|
|
||||||
|
|
||||||
def mouth_neutral(r: int = 11) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 6, "m"), (r, 7, "m"), (r, 8, "m"), (r, 9, "m")]
|
|
||||||
|
|
||||||
|
|
||||||
def mouth_smile(r: int = 11) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 6, "m"), (r, 9, "m"),
|
|
||||||
(r + 1, 7, "m"), (r + 1, 8, "m")]
|
|
||||||
|
|
||||||
|
|
||||||
def mouth_frown(r: int = 11) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 7, "m"), (r, 8, "m"),
|
|
||||||
(r - 1, 6, "m"), (r - 1, 9, "m")]
|
|
||||||
|
|
||||||
|
|
||||||
def mouth_o(r: int = 11) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 7, "m"), (r, 8, "m"),
|
|
||||||
(r + 1, 7, "m"), (r + 1, 8, "m")]
|
|
||||||
|
|
||||||
|
|
||||||
def blush(r: int = 9) -> list[tuple[int, int, str]]:
|
|
||||||
return [(r, 3, "c"), (r, 12, "c")]
|
|
||||||
|
|
||||||
|
|
||||||
# Accent overlays
|
|
||||||
SWEAT_HIGH = [(2, 13, "s"), (3, 13, "s")]
|
|
||||||
SWEAT_LOW = [(5, 13, "s"), (6, 13, "s")]
|
|
||||||
Z_SMALL = [(3, 12, "z"), (3, 13, "z")]
|
|
||||||
Z_LARGE = [(1, 13, "z"), (1, 14, "z"),
|
|
||||||
(2, 13, "z"), (2, 14, "z"),
|
|
||||||
(3, 12, "z"), (3, 13, "z")]
|
|
||||||
SPARKLE_LEFT = [(3, 2, "*"), (5, 1, "*")]
|
|
||||||
SPARKLE_RIGHT = [(3, 14, "*"), (5, 14, "*")]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Frame compositions per mood
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Idle: bob + occasional blink. Most frames are eyes-open at rest.
|
|
||||||
IDLE_REST = _overlay(BODY_REST, eyes_open(8), mouth_neutral(11))
|
|
||||||
IDLE_BOB = _overlay(BODY_BOB, eyes_open(7), mouth_neutral(10))
|
|
||||||
IDLE_BLINK = _overlay(BODY_REST, eyes_blink(8), mouth_neutral(11))
|
|
||||||
|
|
||||||
# Petted: closed-happy eyes + smile + blush. Squish lowers face by 1 row.
|
|
||||||
PETTED_FLAT = _overlay(BODY_SQUISH, eyes_closed_happy(9), mouth_smile(12), blush(10))
|
|
||||||
PETTED_RECOVER = _overlay(BODY_REST, eyes_closed_happy(8), mouth_smile(11), blush(9))
|
|
||||||
|
|
||||||
# Excited: wide eyes, open mouth, sparkles, bouncing
|
|
||||||
EXCITED_LOW = _overlay(BODY_REST, eyes_wide(8), mouth_o(11),
|
|
||||||
SPARKLE_LEFT)
|
|
||||||
EXCITED_HIGH = _overlay(BODY_BOB, eyes_wide(7), mouth_o(10),
|
|
||||||
SPARKLE_LEFT, SPARKLE_RIGHT)
|
|
||||||
|
|
||||||
# Bopping: sway in time with music. Shift face with the body.
|
|
||||||
BOP_LEFT = _overlay(BODY_LEAN_L,
|
|
||||||
_shift_overlay(eyes_open(8), dx=-1),
|
|
||||||
_shift_overlay(mouth_smile(11), dx=-1))
|
|
||||||
BOP_RIGHT = _overlay(BODY_LEAN_R,
|
|
||||||
_shift_overlay(eyes_open(8), dx=1),
|
|
||||||
_shift_overlay(mouth_smile(11), dx=1))
|
|
||||||
|
|
||||||
# Busy: frown + sweat drops
|
|
||||||
BUSY_DROP_HIGH = _overlay(BODY_REST, eyes_open(8), mouth_frown(11), SWEAT_HIGH)
|
|
||||||
BUSY_DROP_LOW = _overlay(BODY_REST, eyes_open(8), mouth_frown(11), SWEAT_LOW)
|
|
||||||
|
|
||||||
# Sleepy: closed eyes, z floats, no mouth
|
|
||||||
SLEEPY_NO_Z = _overlay(BODY_REST, eyes_blink(8))
|
|
||||||
SLEEPY_Z_SMALL = _overlay(BODY_REST, eyes_blink(8), Z_SMALL)
|
|
||||||
SLEEPY_Z_LARGE = _overlay(BODY_REST, eyes_blink(8), Z_LARGE)
|
|
||||||
|
|
||||||
|
|
||||||
# (frames, frame_duration_ms, loop)
|
|
||||||
MOOD_FRAMES: dict[str, tuple[list[list[str]], int, bool]] = {
|
|
||||||
"idle": (
|
|
||||||
[IDLE_REST, IDLE_REST, IDLE_BOB, IDLE_REST, IDLE_REST, IDLE_BLINK],
|
|
||||||
300, True,
|
|
||||||
),
|
|
||||||
"petted": (
|
|
||||||
[PETTED_FLAT, PETTED_FLAT, PETTED_RECOVER, PETTED_RECOVER],
|
|
||||||
180, False,
|
|
||||||
),
|
|
||||||
"excited": (
|
|
||||||
[EXCITED_LOW, EXCITED_HIGH],
|
|
||||||
180, True,
|
|
||||||
),
|
|
||||||
"bopping": (
|
|
||||||
[BOP_LEFT, IDLE_REST, BOP_RIGHT, IDLE_REST],
|
|
||||||
220, True,
|
|
||||||
),
|
|
||||||
"busy": (
|
|
||||||
[BUSY_DROP_HIGH, BUSY_DROP_LOW],
|
|
||||||
260, True,
|
|
||||||
),
|
|
||||||
"sleepy": (
|
|
||||||
[SLEEPY_NO_Z, SLEEPY_NO_Z, SLEEPY_Z_SMALL, SLEEPY_Z_LARGE],
|
|
||||||
500, True,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pixbuf rendering
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_rgba(hex_str: str, alpha: int = 255) -> tuple[int, int, int, int]:
|
|
||||||
h = hex_str.lstrip("#")
|
|
||||||
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
|
|
||||||
|
|
||||||
|
|
||||||
def _mix(c1: tuple[int, int, int], c2: tuple[int, int, int], t: float) -> tuple[int, int, int]:
|
|
||||||
return (int(c1[0] * (1 - t) + c2[0] * t),
|
|
||||||
int(c1[1] * (1 - t) + c2[1] * t),
|
|
||||||
int(c1[2] * (1 - t) + c2[2] * t))
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_palette(mode: str | None = None) -> dict[str, tuple[int, int, int, int]]:
|
|
||||||
colors = resolve_colors(mode)
|
|
||||||
|
|
||||||
body_rgba = _hex_to_rgba(colors["base0D"])
|
|
||||||
body_rgb = body_rgba[:3]
|
|
||||||
light_rgb = _mix(body_rgb, (255, 255, 255), 0.35)
|
|
||||||
dark_rgb = _mix(body_rgb, (0, 0, 0), 0.30)
|
|
||||||
|
|
||||||
return {
|
|
||||||
".": (0, 0, 0, 0),
|
|
||||||
"B": body_rgba,
|
|
||||||
"H": light_rgb + (255,),
|
|
||||||
"b": dark_rgb + (255,),
|
|
||||||
"e": _hex_to_rgba(colors["base00"]),
|
|
||||||
"w": _hex_to_rgba(colors["base05"]),
|
|
||||||
"m": _hex_to_rgba(colors["base00"]),
|
|
||||||
"c": _hex_to_rgba(colors["base08"]),
|
|
||||||
"s": _hex_to_rgba(colors["base0C"]),
|
|
||||||
"z": _hex_to_rgba(colors["base05"]),
|
|
||||||
"*": _hex_to_rgba(colors["base0A"]),
|
|
||||||
"_": _hex_to_rgba(colors["base00"], alpha=80),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _frame_to_pixbuf(rows: list[str], palette: dict[str, tuple[int, int, int, int]]) -> GdkPixbuf.Pixbuf:
|
|
||||||
out_size = SPRITE_SIZE * SCALE
|
|
||||||
rowstride = out_size * 4
|
|
||||||
buf = bytearray(rowstride * out_size)
|
|
||||||
for y in range(SPRITE_SIZE):
|
|
||||||
for x in range(SPRITE_SIZE):
|
|
||||||
ch = rows[y][x]
|
|
||||||
r, g, b, a = palette.get(ch, (0, 0, 0, 0))
|
|
||||||
for sy in range(SCALE):
|
|
||||||
for sx in range(SCALE):
|
|
||||||
o = (y * SCALE + sy) * rowstride + (x * SCALE + sx) * 4
|
|
||||||
buf[o] = r
|
|
||||||
buf[o + 1] = g
|
|
||||||
buf[o + 2] = b
|
|
||||||
buf[o + 3] = a
|
|
||||||
return GdkPixbuf.Pixbuf.new_from_bytes(
|
|
||||||
GLib.Bytes.new(bytes(buf)),
|
|
||||||
GdkPixbuf.Colorspace.RGB,
|
|
||||||
True, 8,
|
|
||||||
out_size, out_size, rowstride,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Widget
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class BuddyWidget(Box):
|
|
||||||
def __init__(self, on_pet=None, **kwargs):
|
|
||||||
super().__init__(name="buddy", v_align="center", **kwargs)
|
|
||||||
theme = get_theme_service()
|
|
||||||
self._palette = _resolve_palette(theme.mode)
|
|
||||||
theme.connect("changed", self._on_theme_changed)
|
|
||||||
self._cache: dict[int, GdkPixbuf.Pixbuf] = {}
|
|
||||||
self._mood = "idle"
|
|
||||||
self._frame_idx = 0
|
|
||||||
self._timer_id = None
|
|
||||||
self._on_pet = on_pet
|
|
||||||
|
|
||||||
out = SPRITE_SIZE * SCALE
|
|
||||||
self._image = Image(name="buddy-image")
|
|
||||||
self._image.set_size_request(out, out)
|
|
||||||
self._button = Button(
|
|
||||||
name="buddy-button",
|
|
||||||
child=self._image,
|
|
||||||
on_clicked=self._handle_click,
|
|
||||||
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
|
|
||||||
)
|
|
||||||
self.children = [self._button]
|
|
||||||
|
|
||||||
self._set_mood("idle")
|
|
||||||
self.show_all()
|
|
||||||
|
|
||||||
def _pixbuf_for(self, frame_rows: list[str]) -> GdkPixbuf.Pixbuf:
|
|
||||||
key = id(frame_rows)
|
|
||||||
if key not in self._cache:
|
|
||||||
self._cache[key] = _frame_to_pixbuf(frame_rows, self._palette)
|
|
||||||
return self._cache[key]
|
|
||||||
|
|
||||||
def _set_mood(self, mood: str):
|
|
||||||
if mood not in MOOD_FRAMES:
|
|
||||||
mood = "idle"
|
|
||||||
self._mood = mood
|
|
||||||
self._frame_idx = 0
|
|
||||||
if self._timer_id is not None:
|
|
||||||
GLib.source_remove(self._timer_id)
|
|
||||||
self._timer_id = None
|
|
||||||
_frames, dur, _loop = MOOD_FRAMES[mood]
|
|
||||||
self._render_current()
|
|
||||||
self._timer_id = GLib.timeout_add(dur, self._tick)
|
|
||||||
|
|
||||||
def _tick(self):
|
|
||||||
frames, _dur, loop = MOOD_FRAMES[self._mood]
|
|
||||||
self._frame_idx += 1
|
|
||||||
if self._frame_idx >= len(frames):
|
|
||||||
if loop:
|
|
||||||
self._frame_idx = 0
|
|
||||||
else:
|
|
||||||
self._frame_idx = len(frames) - 1
|
|
||||||
self._timer_id = None
|
|
||||||
if callable(self._on_pet) and self._mood == "petted":
|
|
||||||
GLib.timeout_add(120, self._notify_petted_done)
|
|
||||||
return False
|
|
||||||
self._render_current()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _render_current(self):
|
|
||||||
frames, _dur, _loop = MOOD_FRAMES[self._mood]
|
|
||||||
rows = frames[self._frame_idx]
|
|
||||||
self._image.set_from_pixbuf(self._pixbuf_for(rows))
|
|
||||||
|
|
||||||
def _notify_petted_done(self):
|
|
||||||
if callable(self._on_pet):
|
|
||||||
self._on_pet(done=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _handle_click(self, *_args):
|
|
||||||
if callable(self._on_pet):
|
|
||||||
self._on_pet(petted=True)
|
|
||||||
|
|
||||||
def _on_theme_changed(self, _theme, mode):
|
|
||||||
self._palette = _resolve_palette(mode)
|
|
||||||
self._cache.clear()
|
|
||||||
self._render_current()
|
|
||||||
|
|
||||||
def update_mood(self, mood: str):
|
|
||||||
if mood == self._mood:
|
|
||||||
return
|
|
||||||
self._set_mood(mood)
|
|
||||||
Reference in New Issue
Block a user