rewrite ui in hy, with python astal bindings

This commit is contained in:
gnat 2025-02-14 01:56:09 -08:00
parent afc9ff29e7
commit ad3e377963
27 changed files with 1106 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
style/style.css

7
astal/__init__.py Normal file
View File

@ -0,0 +1,7 @@
import gi
from .binding import bind
from .variable import Variable
from .file import *
from .time import *
from .process import *

33
astal/binding.py Normal file
View File

@ -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)

30
astal/file.py Normal file
View File

@ -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)

12
astal/gtk3/__init__.py Normal file
View File

@ -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

62
astal/gtk3/app.py Normal file
View File

@ -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()

111
astal/gtk3/astalify.py Normal file
View File

@ -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

33
astal/gtk3/widget.py Normal file
View File

@ -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)

48
astal/process.py Normal file
View File

@ -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)))

20
astal/test.hy Normal file
View File

@ -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"))

18
astal/time.py Normal file
View File

@ -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)

145
astal/variable.py Normal file
View File

@ -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

24
main.hy Normal file
View File

@ -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")

31
style/colors.scss Normal file
View File

@ -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;

27
style/mixins.scss Normal file
View File

@ -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;
}
}

1
style/style.css.map Normal file
View File

@ -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"}

9
style/style.scss Normal file
View File

@ -0,0 +1,9 @@
* {
all: unset;
}
@import 'colors.scss';
@import 'mixins.scss';
@import './widgets/bar.scss';
@import './widgets/notifications.scss';

119
style/widgets/bar.scss Normal file
View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}
}
}

2
widgets/__init__.hy Normal file
View File

@ -0,0 +1,2 @@
(import .bar [bar])
(import .notifications [notifications])

32
widgets/bar/__init__.hy Normal file
View File

@ -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]))))

27
widgets/bar/battery.hy Normal file
View File

@ -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)))))))])))

18
widgets/bar/clock.hy Normal file
View File

@ -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))])))

81
widgets/bar/mpris.hy Normal file
View File

@ -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))))))))

22
widgets/bar/volume.hy Normal file
View File

@ -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))))])))

41
widgets/bar/workspaces.hy Normal file
View File

@ -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)])))

View File

@ -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)))))))))))