diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0b65eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +style/style.css diff --git a/astal/__init__.py b/astal/__init__.py new file mode 100644 index 0000000..f30ad9b --- /dev/null +++ b/astal/__init__.py @@ -0,0 +1,7 @@ +import gi + +from .binding import bind +from .variable import Variable +from .file import * +from .time import * +from .process import * diff --git a/astal/binding.py b/astal/binding.py new file mode 100644 index 0000000..faeaf7e --- /dev/null +++ b/astal/binding.py @@ -0,0 +1,33 @@ +import gi + +gi.require_version("GObject", "2.0") + +from gi.repository import GObject + +# from variable import Variable + +class Binding(GObject.Object): + def __init__(self, emitter: GObject.GObject, property: str | None = None, transform_fn = lambda x: x): + self.emitter = emitter + self.property = property + self.transform_fn = transform_fn + + def get(self): + return self.transform_fn(self.emitter.get_property(self.property) if self.property else self.emitter.get()) + + def transform(self, fn): + return Binding(self.emitter, self.property, lambda x: fn(self.transform_fn(x))) + + def subscribe(self, callback): + id = self.emitter.connect( + f'notify::{self.property}' if self.property else 'changed', + lambda gobject, _=None: callback(self.transform_fn(self.emitter.get_property(self.property) if self.property else self.emitter.get())) + ) + + def unsubscribe(_=None): + self.emitter.disconnect(id) + + return unsubscribe + +def bind(*args, **kwargs): + return Binding(*args, **kwargs) diff --git a/astal/file.py b/astal/file.py new file mode 100644 index 0000000..fcaa9d1 --- /dev/null +++ b/astal/file.py @@ -0,0 +1,30 @@ +import gi +from typing import Callable + +gi.require_version("AstalIO", "0.1") +gi.require_version("GObject", "2.0") + +from gi.repository import AstalIO, GObject + +def read_file(fp: str) -> str: + return AstalIO.read_file(fp) + +def read_file_async(fp: str, callback: Callable[[str | None, Exception | None], None]) -> None: + try: + AstalIO.read_file_async(fp, lambda _, res: callback(AstalIO.read_file_finish(res), None)) + + except Exception as e: + callback(None, e) + +def write_file(fp: str, content: str) -> None: + AstalIO.write_file(fp, content) + +def write_file_async(fp: str, content: str, callback: Callable[[Exception], None]) -> None: + try: + AstalIO.write_file_async(fp, content, lambda _, res: AstalIO.write_file_finish(res)) + + except Exception as e: + callback(e) + +def monitor_file(fp: str, callback: Callable[[str, int], None]) -> None: + return AstalIO.monitor_file(fp, callback) diff --git a/astal/gtk3/__init__.py b/astal/gtk3/__init__.py new file mode 100644 index 0000000..3e5bc5b --- /dev/null +++ b/astal/gtk3/__init__.py @@ -0,0 +1,12 @@ +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Astal", "3.0") + +from .app import App +from .astalify import astalify +from .widget import Widget + +from gi.repository import Gtk, Gdk, Astal + diff --git a/astal/gtk3/app.py b/astal/gtk3/app.py new file mode 100644 index 0000000..8b7f23c --- /dev/null +++ b/astal/gtk3/app.py @@ -0,0 +1,62 @@ +import gi, sys + +from typing import Callable, Optional, Any, Dict + +gi.require_version("Astal", "3.0") +gi.require_version("AstalIO", "0.1") +from gi.repository import Astal, AstalIO, Gio + +type Config = Dict[str, Any] + +class AstalPy(Astal.Application): + request_handler: Optional[Callable[[str, Callable[[Any], None]], None]] = None + + def do_astal_application_request(self, msg: str, conn: Gio.SocketConnection): + print(msg) + if callable(self.request_handler): + def respond(response: Any): + AstalIO.write_sock(conn, str(response), None, None) + self.request_handler(msg, respond) + else: + AstalIO.Application.do_request(self, msg, conn) + + def quit(self, code: int = 0): + super().quit() + sys.exit(code) + + def apply_css(self, css: str, reset: bool = False): + super().apply_css(css, reset) + + def start(self, **config: Any): + config.setdefault("client", lambda *_: (print(f'Astal instance "{self.get_instance_name()}" is already running'), sys.exit(1))) + config.setdefault("hold", True) + + self.request_handler = config.get("request_handler") + + if "css" in config: + self.apply_css(config["css"]) + if "icons" in config: + self.add_icons(config["icons"]) + + for key in ["instance_name", "gtk_theme", "icon_theme", "cursor_theme"]: + if key in config: + self.set_property(key, config[key]) + + def on_activate(_): + if callable(config.get("main")): + config["main"]() + if config["hold"]: + self.hold() + + self.connect("activate", on_activate) + + try: + self.acquire_socket() + except Exception: + return config["client"](lambda msg: AstalIO.send_message(self.get_instance_name(), msg), *sys.argv[1:]) + + self.run() + + return self + +App = AstalPy() diff --git a/astal/gtk3/astalify.py b/astal/gtk3/astalify.py new file mode 100644 index 0000000..16d3c83 --- /dev/null +++ b/astal/gtk3/astalify.py @@ -0,0 +1,111 @@ +import gi +from functools import partial, singledispatch + +from typing import Callable, List + +from astal.binding import Binding, bind +from astal.variable import Variable + +gi.require_version("Astal", "3.0") +gi.require_version("AstalIO", "0.1") +gi.require_version("Gtk", "3.0") +gi.require_version("GObject", "2.0") +from gi.repository import Astal, Gtk, GObject + +def astalify(widget: Gtk.Widget): + class Widget(widget): + class_name = '' + + def hook(self, object: GObject.Object, signal_or_callback: str | Callable, callback: Callable = lambda _, x: x): + if isinstance(signal_or_callback, Callable): + callback = signal_or_callback + + if isinstance(object, Variable): + unsubscribe = object.subscribe(callback) + + else: + if isinstance(signal_or_callback, Callable): return + + if 'notify::' in signal_or_callback: + id = object.connect(f'{signal_or_callback}', lambda obj, *_: callback(self, object.get_property(signal_or_callback.replace('notify::', '').replace('-', '_')) if signal_or_callback.replace('notify::', '') in [*map(lambda x: x.name, object.list_properties())] else None)) + + else: + id = object.connect(signal_or_callback, lambda _, value, *args: callback(self, value) if not args else callback(self, value, *args)) + + unsubscribe = lambda _=None: object.disconnect(id) + + self.connect('destroy', unsubscribe) + + def toggle_class_name(self, name: str, state: bool | None = None): + Astal.widget_toggle_class_name(self, name, state if state is not None else not name in Astal.widget_get_class_names(self)) + + @GObject.Property(type=str) + def class_name(self): + return ' '.join(Astal.widget_get_class_names(self)) + + @class_name.setter + def class_name(self, name): + Astal.widget_set_class_names(self, name.split(' ')) + + @GObject.Property(type=str) + def css(self): + return Astal.widget_get_css(self) + + @css.setter + def css(self, css: str): + Astal.widget_set_css(self, css) + + @GObject.Property(type=str) + def cursor(self): + return Astal.widget_get_cursor(self) + + @cursor.setter + def cursor(self, cursor: str): + Astal.widget_set_cursor(self, cursor) + + @GObject.Property(type=str) + def click_through(self): + return Astal.widget_get_click_through(self) + + @click_through.setter + def click_through(self, click_through: str): + Astal.widget_set_click_through(self, click_through) + + if widget == Astal.Box or widget == Gtk.Box: + @GObject.Property() + def children(self): + return Astal.Box.get_children(self) + + @children.setter + def children(self, children): + Astal.Box.set_children(self, children) + + def __init__(self, **props): + super().__init__() + + if not 'show' in props: + self.show() + + else: + if props['show']: + self.show() + + for prop, value in props.items(): + if isinstance(value, Binding): + self.set_property(prop, value.get()) + unsubscribe = value.subscribe(partial(self.set_property, prop)) + self.connect('destroy', unsubscribe) + + elif 'on_' == prop[0:3] and isinstance(value, Callable): + self.connect(prop.replace('on_', '', 1), value) + + elif prop.replace('_', '-') in map(lambda x: x.name, self.props): + self.set_property(prop.replace('_', '-'), value) + + elif prop == 'setup' and isinstance(value, Callable): + value(self) + + else: + self.__setattr__(prop, value) + + return Widget diff --git a/astal/gtk3/widget.py b/astal/gtk3/widget.py new file mode 100644 index 0000000..be8dc29 --- /dev/null +++ b/astal/gtk3/widget.py @@ -0,0 +1,33 @@ +import gi + +from .astalify import astalify +from enum import Enum + +gi.require_version("Astal", "3.0") +gi.require_version("Gtk", "3.0") + +from gi.repository import Astal, Gtk + +class CallableEnum(Enum): + def __call__(self, *args, **kwargs): + return self.value(*args, **kwargs) + +class Widget(CallableEnum): + Box = astalify(Astal.Box) + Button = astalify(Astal.Button) + CenterBox = astalify(Astal.CenterBox) + CircularProgress = astalify(Astal.CircularProgress) + DrawingArea = astalify(Gtk.DrawingArea) + Entry = astalify(Gtk.Entry) + EventBox = astalify(Astal.EventBox) + Icon = astalify(Astal.Icon) + Label = astalify(Gtk.Label) + LevelBar = astalify(Astal.LevelBar) + MenuButton = astalify(Gtk.MenuButton) + Overlay = astalify(Astal.Overlay) + Revealer = astalify(Gtk.Revealer) + Scrollable = astalify(Astal.Scrollable) + Slider = astalify(Astal.Slider) + Stack = astalify(Astal.Stack) + Switch = astalify(Gtk.Switch) + Window = astalify(Astal.Window) diff --git a/astal/process.py b/astal/process.py new file mode 100644 index 0000000..bd8ae99 --- /dev/null +++ b/astal/process.py @@ -0,0 +1,48 @@ +import gi, sys + +from typing import List, Callable + +gi.require_version("AstalIO", "0.1") + +from gi.repository import AstalIO + +def subprocess(command: str | List[str], output=None, error=None) -> AstalIO.Process | None: + if not output: + output = lambda proc, x: sys.stdout.write(x + '\n') + + if not error: + error = lambda proc, x: sys.stderr.write(x + '\n') + + if isinstance(command, list): + proc = AstalIO.Process.subprocessv(command) + + else: + proc = AstalIO.Process.subprocess(command) + + proc.connect('stdout', output) + proc.connect('stderr', error) + + return proc + +def exec(command: str | List[str]) -> str: + if isinstance(command, list): + return AstalIO.Process.execv(command) + + else: + return AstalIO.Process.exec(command) + +def exec_async(command: str | List[str], callback: Callable[[str, str], None] | None = None) -> None: + def default_callback(output, error=None): + if error: + sys.stderr.write(error + '\n') + + else: sys.stdout.write(output + '\n') + + if not callback: + callback = default_callback + + if isinstance(command, list): + AstalIO.Process.exec_asyncv(command, lambda _, res: callback(AstalIO.Process.exec_asyncv_finish(res))) + + else: + AstalIO.Process.exec_async(command, lambda _, res: callback(AstalIO.Process.exec_finish(res))) diff --git a/astal/test.hy b/astal/test.hy new file mode 100644 index 0000000..a8f638d --- /dev/null +++ b/astal/test.hy @@ -0,0 +1,20 @@ +(import astalify [Label Box]) +(import binding *) +(import app [App]) + +(import gi) +(.require_version gi "Astal" "3.0") + +(import gi.repository [Astal]) + +(.start App (dict + :main (fn [] + (setv test (Astal.Window + :child (Box + :vertical True + :children [ + (Label :label "aoeu") + (Label :label "asdf")]))) + (.show_all test) + (.add_window App test)) + :instance_name "aoeu")) diff --git a/astal/time.py b/astal/time.py new file mode 100644 index 0000000..e94df7d --- /dev/null +++ b/astal/time.py @@ -0,0 +1,18 @@ +import gi + +from typing import Callable + +gi.require_version("AstalIO", "0.1") +gi.require_version("GObject", "2.0") + +from gi.repository import AstalIO, GObject + +def interval(interval: int, callback: Callable) -> AstalIO.Time: + return AstalIO.Time.interval(interval, callback) + +def timeout(timeout: int, callback: Callable) -> AstalIO.Time: + return AstalIO.Time.timeout(timeout, callback) + +def idle(callback: Callable) -> AstalIO.Time: + return AstalIO.Time.idle(callback) + diff --git a/astal/variable.py b/astal/variable.py new file mode 100644 index 0000000..6e4f90d --- /dev/null +++ b/astal/variable.py @@ -0,0 +1,145 @@ +import gi +import asyncio +from .process import * +from .binding import Binding + +gi.require_version("Astal", "3.0") +gi.require_version("AstalIO", "0.1") +gi.require_version("GObject", "2.0") + +from gi.repository import Astal, AstalIO, GObject + +import threading +import time +from typing import Any, Callable, List, Optional, Union + +class Variable(AstalIO.VariableBase): + def __init__(self, init_value=None): + super().__init__() + self.value = init_value + self.watch_proc = None + self.poll_interval = None + self.poll_exec = None + self.poll_transform = None + self.poll_fn = None + self.poll_timer = None + self.watch_transform = None + self.watch_exec = [] + + self.connect('dropped', self._on_dropped) + + def __del__(self): + self.emit_dropped() + + def subscribe(self, callback): + id = self.emitter.connect( + 'changed', + lambda gobject, _=None: callback(self.emitter.get_value()) + ) + + def unsubscribe(_=None): + self.emitter.disconnect(id) + + return unsubscribe + + def get_value(self): + return self.value + + def set_value(self, new_value): + self.value = new_value + self.emit_changed() + + def get(self): + return self.value + + def set(self, new_value): + self.value = new_value + self.emit_changed() + + def poll(self, interval, exec, transform=lambda x: x): + self.stop_poll() + self.poll_transform = transform + self.poll_interval = interval + if isinstance(exec, Callable): + self.poll_fn = exec + + else: + self.poll_exec = exec + + self.start_poll() + return self + + def start_poll(self): + if self.is_polling(): return + if not self.poll_transform: return + + if self.poll_fn: + self.poll_timer = AstalIO.Time.interval(self.poll_interval, lambda: self.set_value(self.poll_transform(self.poll_fn(self.get_value())))) + + else: + self.poll_timer = AstalIO.Time.interval( + self.poll_interval, + lambda: exec_async(self.poll_exec, lambda out, err=None: + self.set_value(self.poll_transform(out, self.get_value())) + ) + ) + + def stop_poll(self): + if self.is_polling(): + self.poll_timer.cancel() + self.poll_timer = None + + return self + + def watch(self, exec, transform=lambda x, _: x): + self.stop_watch() + self.watch_transform = transform + self.watch_exec = exec + self.start_watch() + return self + + def start_watch(self): + if self.is_watching(): return + if not self.watch_transform: return + + self.watch_proc = subprocess( + self.watch_exec, + lambda _, out: self.set_value(self.watch_transform(out, self.get_value())), + ) + + def stop_watch(self): + if self.is_watching(): + self.watch_proc.kill() + self.watch_proc = None + + return self + + def _on_dropped(self, *_): + if self.is_polling(): + self.stop_poll() + if self.is_watching(): + self.stop_watch() + + def is_polling(self): + return self.poll_timer != None + + def is_watching(self): + return self.watch_proc != None + + def observe(self, object, signal, callback=lambda _, x: x): + # not sure about this + object.connect(signal, lambda *args: self.set_value(callback(*args))) + + return self + + @classmethod + def derive(cls, objects: List[Union[Binding, 'Variable']], transform=lambda *args: args): + update = lambda: transform(*map(lambda object: object.get(), objects)) + + derived = Variable(update()) + + unsubs = [*map(lambda object: object.subscribe(lambda *_: derived.set(update())), objects)] + + derived.connect('dropped', lambda *_: map(lambda unsub: unsub(), unsubs)) + + return derived diff --git a/main.hy b/main.hy new file mode 100644 index 0000000..6786251 --- /dev/null +++ b/main.hy @@ -0,0 +1,24 @@ +(import astal *) +(import astal.gtk3 *) + +(import glob [glob]) + +(import widgets) + +(defn compile-scss [] + (exec "sass style/style.scss style/style.css")) + +;; (defn watch-style [] +;; (lfor file (glob "./style/**" :recursive True) +;; (when (not (in ".css" file)) (monitor-file file (fn [_, op] (when (= op 1) (print file op) (compile-scss) (.apply-css App "./style/style.css"))))))) + +(compile-scss) + +(.start App + :main (fn [] + ;; (.show-all widgets.bar) + ;; (.add-window App widgets.bar) + (.show-all widgets.notifications) + (.add-window App widgets.notifications)) + :instance-name "hy-test" + :css "./style/style.css") diff --git a/style/colors.scss b/style/colors.scss new file mode 100644 index 0000000..54c4e92 --- /dev/null +++ b/style/colors.scss @@ -0,0 +1,31 @@ +$bg: #161616; +$bg-alt-1: #262626; +$bg-alt-2: #393939; + +$fg: #f2f4f8; + +$hl: #FF7EB6; +$hl-alt-1: #33B1FF; +$hl-alt-2: #42be65; + +$icon-color: #f2f4f8; +$button-hover-color: #FF7EB6; + +$battery-dial-bg: #42be65; +$battery-dial-fg: #161616; + +$volume-dial-bg: #33B1FF; +$volume-dial-fg: #161616; + +$brightness-dial-bg: #33B1FF; +$brightness-dial-fg: #161616; + +$ws-active: #FF7EB6; +$ws-inactive: $bg; + +$mpd-progress-primary: #FF7Eb6; + +$resource-dial-fg-cpu: #FF7eb6; +$resource-dial-fg-mem: #FF7eb6; +$resource-dial-fg-tmp: #FF7eb6; +$resource-dial-fg-fan: #FF7eb6; diff --git a/style/mixins.scss b/style/mixins.scss new file mode 100644 index 0000000..4008733 --- /dev/null +++ b/style/mixins.scss @@ -0,0 +1,27 @@ +@mixin dial($_fg, $_bg, $margin, $padding, $font-size, $icon-font-size) { + color: $_fg; + background-color: $_bg; + margin: $margin; + padding: $padding; + font-size: $font-size; + + label, icon { + font-family: 'Symbols Nerd Font Mono'; + font-size: $icon-font-size; + color: $icon-color; + } +} + +@mixin bar-dial($_fg, $_bg) { + @include dial($_fg, $_bg, 0px, 0px, 5px, 16px) +} + +@mixin panel-dial($_fg, $_bg) { + @include dial($_fg, $_bg, 0px, 10px, 8px, 32px); + margin-left: 10px; + border-radius: 10px; + .resource-dial { + min-width: 96px; + min-height: 96px; + } +} diff --git a/style/style.css.map b/style/style.css.map new file mode 100644 index 0000000..57292fb --- /dev/null +++ b/style/style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["style.scss","widgets/bar.scss","colors.scss","mixins.scss","widgets/notifications.scss"],"names":[],"mappings":"AAAA;EACE;;;ACCF;EACE;;;AAKE;EACE,YATG;EAUH,YCVD;EDWC;EACA;;AAEA;EACE;EACA;EACA;EACA,kBCjBG;;ADmBH;EACE,kBCnBC;;ADsBH;EACE,kBCnBL;;ADyBD;EACE;EACA;EACA;EACA;EACA;;AAGE;EACE;;AAIJ;EACE;EACA;EACA;;AAEA;EACE;EACA,YClDC;;ADqDH;EACE;EACA,YC/Ba;;ADqCrB;EACE,YA/DK;EAgEL,YChEC;;ADkED;EACE;;AAEE;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;EACA,YChFD;;ADmFD;EACE,YC9ED;;ADoFP;EACE;EACA;;AACA;EACE;EACA;EE/FN,ODYgB;ECXhB,kBDYgB;ECXhB,QAY0B;EAX1B,SAW+B;EAV/B,WAUoC;;AARpC;EACE;EACA,WAMuC;EALvC;;AF2FA;EACE,YCrGK;EDsGL;EACA;EACA;;AAGF;EACE;EACA,YC9GD;;ADgHC;EACE;EACA;;;AGhHJ;EACE;EACA;EACA,YFLD;;AEOC;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAGF;EACE,YFpBG;;AEsBH;EACE;EACA,kBFlBC;;AEsBL;EACE;;AAEA;EACE,YFzBC;;AE4BH;EACE,YF/BL","file":"style.css"} \ No newline at end of file diff --git a/style/style.scss b/style/style.scss new file mode 100644 index 0000000..ea4d82e --- /dev/null +++ b/style/style.scss @@ -0,0 +1,9 @@ +* { + all: unset; +} + +@import 'colors.scss'; +@import 'mixins.scss'; + +@import './widgets/bar.scss'; +@import './widgets/notifications.scss'; diff --git a/style/widgets/bar.scss b/style/widgets/bar.scss new file mode 100644 index 0000000..45d27d5 --- /dev/null +++ b/style/widgets/bar.scss @@ -0,0 +1,119 @@ +$height: 50px; + +bar { + background: transparent; +} + +centerbox { + > box.left { + box.workspaces { + min-height: $height; + background: $bg; + padding: 5px; + padding-top: 15px; + + box > button { + min-width: 10px; + min-height: 10px; + margin: 5px; + background-color: $bg-alt-1; + + &.occupied { + background-color: $bg-alt-2; + } + + &.focused { + background-color: $hl; + } + } + + } + + stack > box.player { + min-width: 250px; + min-height: 60px; + background-position: 50% 50%; + background-size: cover; + border: 4px solid #161616; + + > box { + > button { + margin: 3px 10px; + } + } + + > .media-progress { + min-width: 242px; + min-height: 2px; + background: transparent; + + trough { + min-height: 2px; + background: $bg-alt-1; + } + + highlight { + min-height: 2px; + background: $mpd-progress-primary; + } + } + } + } + + > box.right { + min-height: $height; + background: $bg; + + box.sliders { + margin: 5px; + box.volume-slider { + > button { + font-size: 20px; + margin: 5px; + } + + > .volume-slider { /* selecting this as `slider` doesn't work, for some reason.*/ + min-width: 120px; + min-height: 10px; + + & trough { + min-height: 10px; + min-width: 120px; + background: $bg-alt-1; + } + + & highlight { + background: $hl-alt-1; + } + } + } + } + + .battery-container { + padding-left: 10px; + padding-right: 10px; + .battery-dial { + min-width: 40px; + min-height: 40px; + @include bar-dial($battery-dial-bg, $battery-dial-fg); + } + } + + separator { + background: $bg-alt-1; + padding: 1px; + margin-top: 10px; + margin-bottom: 10px; + } + + box.clock { + margin: 10px; + background: $bg; + + .datetime { + margin: 1px; + font-size: 10pt; + } + } + } +} diff --git a/style/widgets/notifications.scss b/style/widgets/notifications.scss new file mode 100644 index 0000000..ae33ae3 --- /dev/null +++ b/style/widgets/notifications.scss @@ -0,0 +1,44 @@ +.notifications { + > * { + > button > box { + margin: 10px 15px; + border: 1px solid $fg; + background: $bg; + + .title { + font-family: 'tewi'; + font-size: 22px; + min-width: 16rem; + } + + .icon { + min-width: 64px; + min-height: 64px; + margin: 5px; + font-size: 58px; + } + + .timeout-bar { + background: $bg-alt-1; + + > trough > progress { + background-image: none; + background-color: $hl-alt-1; + } + } + + .urgency-indicator { + min-width: 25px; + + &.NORMAL, &.LOW { + background: $hl-alt-2; + } + + &.CRITICAL { + background: $hl; + } + } + } + } +} + diff --git a/widgets/__init__.hy b/widgets/__init__.hy new file mode 100644 index 0000000..12ccb83 --- /dev/null +++ b/widgets/__init__.hy @@ -0,0 +1,2 @@ +(import .bar [bar]) +(import .notifications [notifications]) diff --git a/widgets/bar/__init__.hy b/widgets/bar/__init__.hy new file mode 100644 index 0000000..0cf8152 --- /dev/null +++ b/widgets/bar/__init__.hy @@ -0,0 +1,32 @@ +(import astal.gtk3 *) +(import astal *) + +(import .workspaces [workspaces]) +(import .mpris [mpris-controls]) +(import .clock [clock]) +(import .battery [battery-dial]) +(import .volume [volume]) + +(setv bar (Widget.Window + :namespace "bar" + :name "bar" + :anchor (| Astal.WindowAnchor.TOP Astal.WindowAnchor.LEFT Astal.WindowAnchor.RIGHT) + :exclusivity Astal.Exclusivity.EXCLUSIVE + :child (Widget.CenterBox + :start-widget (Widget.Box + :class-name "left" + :children [ + workspaces + mpris-controls]) + :end-widget (Widget.Box + :class-name "right" + :halign Gtk.Align.END + :children [ + (Widget.Box + :class-name "sliders" + :vertical True + :children [ + volume]) + battery-dial + ((astalify Gtk.Separator)) + clock])))) diff --git a/widgets/bar/battery.hy b/widgets/bar/battery.hy new file mode 100644 index 0000000..8c4ffd1 --- /dev/null +++ b/widgets/bar/battery.hy @@ -0,0 +1,27 @@ +(import astal *) +(import astal.gtk3 *) + +(.require-version gi "AstalBattery" "0.1") + +(import gi.repository [AstalBattery :as Battery]) + +(let [ + battery (.get-default Battery) + icons [ + ["󰂎" "󰁺" "󰁻" "󰁼" "󰁽" "󰁾" "󰁿" "󰂀" "󰂁" "󰂂" "󰁹"] + ["󰢟" "󰢜" "󰂆" "󰂇" "󰂈" "󰢝" "󰂉" "󰢞" "󰂊" "󰂋" "󰂅"]]] + (setv battery-dial (Widget.Box + :class-name "battery-container" + :children [ + (Widget.CircularProgress + :class-name "battery-dial" + :rounded False + :inverted False + :start-at -.25 + :end-at .75 + :value (bind battery "percentage") + :child (Widget.Label + :halign Gtk.Align.CENTER + :hexpand True + :justify 2 + :label (.transform (bind battery "percentage") (fn [percentage] (get (get icons (.get-charging battery)) (round (* percentage 10)))))))]))) diff --git a/widgets/bar/clock.hy b/widgets/bar/clock.hy new file mode 100644 index 0000000..1732441 --- /dev/null +++ b/widgets/bar/clock.hy @@ -0,0 +1,18 @@ +(import astal *) +(import astal.gtk3 *) + +(let [ + datetime (.poll (Variable "") 1000 "date +'%d %b %H:%M:%S'" (fn [out _] out)) + unix-seconds (.poll (Variable "") 1000 "date +%s" (fn [out _] out))] + (setv clock (Widget.Box + :class-name "clock" + :vertical True + :valign Gtk.Align.CENTER + :children [ + (Widget.Label + :halign Gtk.Align.START + :label (bind datetime)) + (Widget.Label + :halign Gtk.Align.START + :label (bind unix-seconds))]))) + diff --git a/widgets/bar/mpris.hy b/widgets/bar/mpris.hy new file mode 100644 index 0000000..104706e --- /dev/null +++ b/widgets/bar/mpris.hy @@ -0,0 +1,81 @@ +(import astal *) +(import astal.gtk3 *) + +(import math) + +(.require-version gi "AstalMpris" "0.1") + +(import gi.repository [AstalMpris :as Mpris]) + +(let [mpris (.get-default Mpris)] + (setv mpris-controls + (Widget.Stack + :transition-type Gtk.StackTransitionType.SLIDE_UP_DOWN + :transition-duration 125 + :children [] + :setup (fn [self] + (.add-events self Gdk.EventMask.SCROLL_MASK) + (.add-events self Gdk.EventMask.SMOOTH_SCROLL_MASK) + + (defn add-player [player] + (.add-named self + (Widget.Box + :class-name "player" + :vertical True + :hexpand False + :setup (fn [self] (.set-name self (.get-bus-name player))) + :css (.transform (bind player "cover-art") (fn [cover-uri] + (when cover-uri (return f" + background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(22, 22, 22, 0.925)), url(\"{cover-uri}\"); + background-position: 50% 50%; + background-size: cover; + ")) + (return ""))) + :children [ + (Widget.Box + :vertical True + :vexpand True + :valign Gtk.Align.CENTER + :halign Gtk.Align.END + :children [ + (Widget.Button + :on-clicked (fn [#* _] (.previous player)) + :child (Widget.Icon :icon "media-skip-backward-symbolic")) + (Widget.Button + :on-clicked (fn [#* _] (.play-pause player)) + :child (Widget.Icon :icon (.transform (bind player "playback-status") (fn [status] + (when (= status Mpris.PlaybackStatus.PLAYING) + (return "media-playback-pause-symbolic")) + (return "media-playback-start-symbolic"))))) + (Widget.Button + :on-clicked (fn [#* _] (.next player)) + :child (Widget.Icon :icon "media-skip-forward-symbolic"))]) + (Widget.Slider + :hexpand True + :class-name "media-progress" + :valign Gtk.Align.END + :halign Gtk.Align.START + :on-dragged (fn [self] (.set-position player (* (.get-value self) (.get-length player)))) + :setup (fn [self] + (.poll (Variable) 500 (fn [#* _] + (when player + (.set-value self (/ (.get-position player) (.get-length player))))))))]) + (.get-bus-name player))) + + (for [player (.get-players mpris)] (add-player player)) + + (.hook self mpris "player-added" (fn [self player] + (add-player player))) + + (.hook self mpris "player-closed" (fn [self player] + (.destroy (.get-named self (.get-bus-name player))))) + + (setv accumulated-delta-y 0) + + (.hook self self "scroll-event" (fn [_ event] + (nonlocal accumulated-delta-y) + (setv accumulated-delta-y (+ accumulated-delta-y (get (.get-scroll-deltas event) 2))) + + (when (> (abs accumulated-delta-y) 10) + (.set-visible-child self (get (.get-children self) (% (+ (.index (lfor child (.get-children self) (.get-name child)) (.get-shown self)) (* 1 (round (/ accumulated-delta-y 10)))) (len (.get-children self))))) + (setv accumulated-delta-y 0)))))))) diff --git a/widgets/bar/volume.hy b/widgets/bar/volume.hy new file mode 100644 index 0000000..369fbc9 --- /dev/null +++ b/widgets/bar/volume.hy @@ -0,0 +1,22 @@ +(import astal *) +(import astal.gtk3 *) + +(.require-version gi "AstalWp" "0.1") + +(import gi.repository [AstalWp]) + +(let [ + endpoint (. AstalWp (get-default) (get-audio) (get-default-speaker))] + (setv volume (Widget.Box + :class-name "volume-slider" + :children [ + (Widget.Button + :child (Widget.Icon :icon (bind endpoint "volume-icon")) + :on-clicked (fn [#* _] (.set-mute endpoint (not (.get-mute endpoint))))) + (Widget.Slider + :class-name "volume-slider" + :hexpand True + :draw-value False + :value (bind endpoint "volume") + :on-dragged (fn [self] + (.set-volume endpoint (.get-value self))))]))) diff --git a/widgets/bar/workspaces.hy b/widgets/bar/workspaces.hy new file mode 100644 index 0000000..b15ffb7 --- /dev/null +++ b/widgets/bar/workspaces.hy @@ -0,0 +1,41 @@ +(import astal *) +(import astal.gtk3 *) + +(.require-version gi "AstalHyprland" "0.1") + +(import gi.repository [AstalHyprland :as Hyprland]) + + +(let [ + hyprland (.get-default Hyprland) + workspace-row (fn [start stop] + (Widget.Box + :children (lfor i (range start stop) + (Widget.Button + :class-name "workspace" + :attribute (+ i 1) + :on-clicked (fn [self] (.message-async hyprland f"dispatch workspace {self.attribute}")) + :setup (fn [self] + (.hook self hyprland "notify::focused-workspace" (fn [self, w] (.toggle-class-name self "focused" (= (.get-id w) self.attribute)))) + (defn update [#* _] + (let [workspace (.get-workspace hyprland self.attribute)] + (when (!= workspace None) + (.toggle-class-name self "occupied" (< 0 (len (.get-clients workspace))))))) + + (.hook self hyprland "notify::workspaces" update) + (.hook self hyprland "notify::clients" update) + (.hook self hyprland "client-moved" update) + (update) + + (when (= (.get-id (.get-focused-workspace hyprland)) self.attribute) + (.toggle-class-name self "focused")))))))] + + (setv workspaces (Widget.Box + :class_name "workspaces" + :vertical True + :hexpand False + :halign Gtk.Align.START + :valign Gtk.Align.CENTER + :children [ + (workspace-row 0 5) + (workspace-row 5 10)]))) diff --git a/widgets/notifications/__init__.hy b/widgets/notifications/__init__.hy new file mode 100644 index 0000000..49faddc --- /dev/null +++ b/widgets/notifications/__init__.hy @@ -0,0 +1,107 @@ +(import astal *) +(import astal.gtk3 *) + +(.require-version gi "AstalNotifd" "0.1") +(.require-version gi "Pango" "1.0") + +(import gi.repository [AstalNotifd Pango]) + +(let [ + ProgressBar (astalify Gtk.ProgressBar) + notifd (.get-default AstalNotifd) + notification-timeout 3 + notification-icon (fn [notification] + (cond + (.get-image notification) (Widget.Box + :class-name "icon image" + :css f" + background-image: url(\"{(.get-image notification)}\"); + background-size: contain; + background-repeat: no-repeat; + background-position: center;") + (. Astal Icon (lookup-icon (.get-app-icon notification))) (Widget.Icon + :icon (.get-app-icon notification) + :class-name "icon") + True (Widget.Icon + :icon "dialog-information-symbolic" + :class-name "icon"))) + + make-notification (fn [notification] + (let [ + layout (Widget.Button + :on-clicked (fn [self] (. self (get-parent) (set-reveal-child False))) + :child (Widget.Box + :children [ + (Widget.Box + :vertical True + :css "min-width: 200px; min-height 50px;;" + :children [ + (Widget.Box + :children [ + (notification-icon notification) + (Widget.Box + :vertical True + :children [ + (Widget.Label + :class-name "title" + :label (str (.get-summary notification)) + :xalign 0 + :justify 0 + :ellipsize Pango.EllipsizeMode.END + ) + (Widget.Label + :class-name "body" + :label (str (.get-body notification)) + :xalign 0 + :justify 0 + :wrap True + :wrap-mode Pango.WrapMode.WORD_CHAR + :use-markup True + )])]) + (ProgressBar + :class-name "timeout-bar" + :hexpand True + :valign 2 + :fraction (bind (.poll (Variable 1) (// (+ (* (or (max (.get-expire-timeout notification) 0) notification-timeout) 1000) 250) 100) (fn [prev] + (when (> prev .02) + (return (- prev .01))) + (return 0)))))]) + (Widget.Box :class-name f"urgency-indicator {(.get-urgency notification)}")]))] + + (Widget.Revealer + :transition-type Gtk.RevealerTransitionType.SLIDE_DOWN + :transition-duration 250 + :class-name "notifications" + :child (Widget.Revealer + :transition-type Gtk.RevealerTransitionType.SLIDE_DOWN + :transition-duration 250 + :child layout + :setup (fn [self] + (.hook self self "notify::reveal-child" (fn [#* _] + (when (not (.get-reveal-child self)) + (timeout 250 (fn [] (. self (get-parent) (set-reveal-child False))))))))) + :setup (fn [self] + (.hook self self "notify::reveal-child" (fn [self revealed?] + (when revealed? + (when (.get-reveal-child self) (timeout 1 (fn [] (. self (get-child) (set-reveal-child True))))) + (timeout (* 1000 (or (max (.get-expire-timeout notification) 0) notification-timeout)) (fn [] + (. self (get-child) (set-reveal-child False)) + (timeout (. self (get-child) (get-transition-duration)) (fn [] + (.set-reveal-child self False) + (timeout (.get-transition-duration self) (fn [] + (.destroy self)))))))))) + + (.set-reveal-child self True)))))] + + (setv notifications (Widget.Window + :namespace "notifications" + :name "notifications" + :anchor (| Astal.WindowAnchor.TOP Astal.WindowAnchor.RIGHT) + :exclusivity Astal.Exclusivity.EXCLUSIVE + :margin-top 5 + :child (Widget.Box + :vertical True + :setup (fn [self] + (.hook self notifd "notified" (fn [self notification _] + (let [children (.get-children self)] + (.add self (make-notification (.get-notification notifd notification)))))))))))