From 85acb54fd959f9716969006dd51394a0963dbef9 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:01:52 +0900 Subject: [PATCH 01/85] chore: add tmporary dir --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e08b5c7..06e7200 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ oneshot/style.css oneshot/**/build/ debug/results/ + +JSX* From 5c1b3587022e42a2d7efe9436ed325982c2f14cb Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:03:24 +0900 Subject: [PATCH 02/85] refactor: migrate app launcher widgets to tsx --- src/stores/appLauncher.ts | 27 ++ src/widget/app-launcher/index.tsx | 98 ++++--- src/widget/app-launcher/widget/AppItem.ts | 68 ----- src/widget/app-launcher/widget/AppItem.tsx | 61 ++++ src/widget/app-launcher/widget/AppList.tsx | 260 ++++++++++-------- .../app-launcher/widget/SearchGoogleBtn.tsx | 16 +- .../app-launcher/widget/SearchInput.tsx | 44 ++- 7 files changed, 319 insertions(+), 255 deletions(-) create mode 100644 src/stores/appLauncher.ts delete mode 100644 src/widget/app-launcher/widget/AppItem.ts create mode 100644 src/widget/app-launcher/widget/AppItem.tsx diff --git a/src/stores/appLauncher.ts b/src/stores/appLauncher.ts new file mode 100644 index 0000000..9c0e831 --- /dev/null +++ b/src/stores/appLauncher.ts @@ -0,0 +1,27 @@ +import { type Accessor, type Setter, createState } from 'ags'; + +import Apps from 'gi://AstalApps'; + +export interface AppLauncherState { + text: Accessor; + setText: Setter; + selectedIndex: Accessor; + setSelectedIndex: Setter; + results: Accessor; + setResults: Setter; +} + +export function createAppLauncherState(): AppLauncherState { + const [text, setText] = createState(''); + const [selectedIndex, setSelectedIndex] = createState(0); + const [results, setResults] = createState([]); + + return { + text, + setText, + selectedIndex, + setSelectedIndex, + results, + setResults, + }; +} diff --git a/src/widget/app-launcher/index.tsx b/src/widget/app-launcher/index.tsx index 63caf0a..06cacbe 100644 --- a/src/widget/app-launcher/index.tsx +++ b/src/widget/app-launcher/index.tsx @@ -1,39 +1,75 @@ -import { createState } from 'ags'; import { Astal, Gdk, Gtk } from 'ags/gtk4'; import app from 'ags/gtk4/app'; -import Apps from 'gi://AstalApps'; import GLib from 'gi://GLib'; +import { createAppLauncherState } from '../../stores/appLauncher'; import { ensureLauncherBackground, registerLauncherBackground } from '../../stores/launcherImage'; import { AppList } from './widget/AppList'; import { SearchInput } from './widget/SearchInput'; -export default function AppLauncher(gdkmonitor: Gdk.Monitor) { - const [text, setText] = createState(''); - const [selectedIndex, setSelectedIndex] = createState(0); - const monitorConnector = gdkmonitor.get_connector(); - - let currentResults: Apps.Application[] = []; - const launcherBackground = new Gtk.Picture({ +function createLauncherBackground() { + const picture = new Gtk.Picture({ contentFit: Gtk.ContentFit.COVER, canTarget: false, canShrink: true, }); - launcherBackground.set_hexpand(true); - launcherBackground.set_vexpand(true); - launcherBackground.set_halign(Gtk.Align.FILL); - launcherBackground.set_valign(Gtk.Align.FILL); - launcherBackground.set_size_request(1, 1); - const unregisterLauncherBackground = registerLauncherBackground(launcherBackground); - launcherBackground.connect('destroy', unregisterLauncherBackground); + + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_halign(Gtk.Align.FILL); + picture.set_valign(Gtk.Align.FILL); + picture.set_size_request(1, 1); + + const unregister = registerLauncherBackground(picture); + picture.connect('destroy', unregister); + + return picture; +} + +function resetLauncherState( + searchInput: Gtk.Entry, + setText: (text: string) => void, + setSelectedIndex: (index: number) => void, +) { + searchInput.set_text(''); + setText(''); + setSelectedIndex(0); +} + +function focusLauncher(searchInput: Gtk.Entry, appList: Gtk.ScrolledWindow) { + ensureLauncherBackground(); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + searchInput.grab_focus(); + appList.get_vadjustment()?.set_value(0); + return GLib.SOURCE_REMOVE; + }); +} + +function addEscapeHandler(window: Astal.Window) { + const controller = new Gtk.EventControllerKey(); + controller.connect('key-pressed', (_, keyval) => { + if (keyval !== Gdk.KEY_Escape) return false; + + window.set_visible(false); + return true; + }); + window.add_controller(controller); +} + +export default function AppLauncher(monitor: Gdk.Monitor) { + const { text, setText, selectedIndex, setSelectedIndex, results, setResults } = + createAppLauncherState(); + const monitorConnector = monitor.get_connector(); + + const launcherBackground = createLauncherBackground(); const searchInput = SearchInput({ text, setText, selectedIndex, setSelectedIndex, - getResults: () => currentResults, + results, monitorConnector, }); @@ -49,17 +85,16 @@ export default function AppLauncher(gdkmonitor: Gdk.Monitor) { const appList = AppList({ text, selectedIndex, + results, + setResults, monitorConnector, - onResultsChanged: (results) => { - currentResults = results; - }, }); const win = ( { if (!self.visible) { - (searchInput as Gtk.Entry).set_text(''); - setText(''); - setSelectedIndex(0); + resetLauncherState(searchInput, setText, setSelectedIndex); } else { - ensureLauncherBackground(); - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - searchInput.grab_focus(); - appList.get_vadjustment()?.set_value(0); - return GLib.SOURCE_REMOVE; - }); + focusLauncher(searchInput, appList); } }} > @@ -112,15 +140,7 @@ export default function AppLauncher(gdkmonitor: Gdk.Monitor) { ) as Astal.Window; - const winKeyCtrl = new Gtk.EventControllerKey(); - winKeyCtrl.connect('key-pressed', (_, keyval) => { - if (keyval === Gdk.KEY_Escape) { - win.set_visible(false); - return true; - } - return false; - }); - win.add_controller(winKeyCtrl); + addEscapeHandler(win); return win; } diff --git a/src/widget/app-launcher/widget/AppItem.ts b/src/widget/app-launcher/widget/AppItem.ts deleted file mode 100644 index a438aa1..0000000 --- a/src/widget/app-launcher/widget/AppItem.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Gtk } from 'ags/gtk4'; - -import Apps from 'gi://AstalApps'; -import Pango from 'gi://Pango'; - -import { recordAppLaunch } from '../../../stores/application'; -import { toggleAppLauncher } from '../../../stores/windowManager'; - -export function createAppItem(res: Apps.Application, monitorConnector: string | null) { - const btn = new Gtk.Button({ - cssClasses: ['applauncher-item'], - canFocus: false, - }); - - const box = new Gtk.Box({ - orientation: Gtk.Orientation.HORIZONTAL, - spacing: 12, - }); - - const iconStr = res.iconName || 'application-x-executable'; - const iconProps: { cssClasses: string[]; file?: string; iconName?: string } = { - cssClasses: ['applauncher-item-icon'], - }; - - if (iconStr.startsWith('/')) { - iconProps.file = iconStr; - } else { - iconProps.iconName = iconStr; - } - - const icon = new Gtk.Image(iconProps); - - const textBox = new Gtk.Box({ - orientation: Gtk.Orientation.VERTICAL, - valign: Gtk.Align.CENTER, - }); - - const nameLabel = new Gtk.Label({ - label: res.name, - halign: Gtk.Align.START, - cssClasses: ['applauncher-item-name'], - }); - - textBox.append(nameLabel); - - if (res.description) { - const descLabel = new Gtk.Label({ - label: res.description, - halign: Gtk.Align.START, - cssClasses: ['applauncher-item-desc'], - ellipsize: Pango.EllipsizeMode.END, - maxWidthChars: 40, - }); - textBox.append(descLabel); - } - - box.append(icon); - box.append(textBox); - btn.set_child(box); - - btn.connect('clicked', () => { - toggleAppLauncher(monitorConnector); - recordAppLaunch(res); - res.launch(); - }); - - return btn; -} diff --git a/src/widget/app-launcher/widget/AppItem.tsx b/src/widget/app-launcher/widget/AppItem.tsx new file mode 100644 index 0000000..bc4508a --- /dev/null +++ b/src/widget/app-launcher/widget/AppItem.tsx @@ -0,0 +1,61 @@ +import { Gtk } from 'ags/gtk4'; + +import Apps from 'gi://AstalApps'; +import Pango from 'gi://Pango'; + +import { recordAppLaunch } from '../../../stores/application'; +import { toggleAppLauncher } from '../../../stores/windowManager'; + +export interface AppItemProps { + res: Apps.Application; + monitorConnector: string | null; +} + +function createImageProp(iconStr: string): Partial { + const iconProps: Partial = { + cssClasses: ['applauncher-item-icon'], + }; + + if (iconStr.startsWith('/')) { + iconProps.file = iconStr; + } else { + iconProps.iconName = iconStr; + } + + return iconProps; +} + +export function createAppItem({ res, monitorConnector }: AppItemProps): Gtk.Button { + return ( + + ) as Gtk.Button; +} diff --git a/src/widget/app-launcher/widget/AppList.tsx b/src/widget/app-launcher/widget/AppList.tsx index 207798f..cbe07f8 100644 --- a/src/widget/app-launcher/widget/AppList.tsx +++ b/src/widget/app-launcher/widget/AppList.tsx @@ -1,3 +1,4 @@ +import type { Accessor, Setter } from 'ags'; import { Gtk } from 'ags/gtk4'; import Apps from 'gi://AstalApps'; @@ -7,90 +8,136 @@ import { searchApps } from '../../../stores/application'; import { createAppItem } from './AppItem'; import { SearchGoogleBtn } from './SearchGoogleBtn'; -interface State { - get: () => T; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - as: (cb: (v: T) => any) => any; - subscribe: (cb: () => void) => () => void; +export interface AppListProps { + text: Accessor; + selectedIndex: Accessor; + results: Accessor; + setResults: Setter; + monitorConnector: string | null; } -export function AppList({ - text, - selectedIndex, - monitorConnector, - onResultsChanged, -}: { - text: State; - selectedIndex: State; - monitorConnector: string | null; - onResultsChanged: (results: Apps.Application[]) => void; -}) { - const searchGoogleBtn = SearchGoogleBtn({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - textState: text as any, - monitorConnector, - }); +function getAppKey(appInstance: Apps.Application) { + return appInstance.name + (appInstance.description || '') + (appInstance.iconName || ''); +} - const appList = () as Gtk.Box; - const widgetMap = new Map(); +function getUniqueResults(query: string) { + const uniqueResults: Apps.Application[] = []; + const seen = new Set(); + + for (const appInstance of searchApps(query)) { + const key = getAppKey(appInstance); + if (seen.has(key)) continue; - function getAppKey(appInstance: Apps.Application) { - return appInstance.name + (appInstance.description || '') + (appInstance.iconName || ''); + seen.add(key); + uniqueResults.push(appInstance); } - let currentResults: Apps.Application[] = []; + return uniqueResults; +} - function populateApps() { - const safeT = text.get() || ''; - const q = safeT.trim().toLowerCase(); +function syncAppWidgets( + appList: Gtk.Box, + widgetMap: Map, + currentResults: Apps.Application[], + monitorConnector: string | null, +) { + const resultKeys = new Set(currentResults.map((appInstance) => getAppKey(appInstance))); + + for (const [key, widget] of widgetMap) { + if (!resultKeys.has(key)) { + appList.remove(widget); + widgetMap.delete(key); + } + } - const rawResults = searchApps(q); - currentResults = []; - const seen = new Set(); - rawResults.forEach((res) => { - const key = getAppKey(res); - if (!seen.has(key)) { - seen.add(key); - currentResults.push(res); - } - }); - onResultsChanged(currentResults); + let previousWidget: Gtk.Widget | null = null; + for (const appInstance of currentResults) { + const key = getAppKey(appInstance); + let widget = widgetMap.get(key); - const currentKeys = new Set(currentResults.map((res) => getAppKey(res))); - for (const [key, w] of widgetMap) { - if (!currentKeys.has(key)) { - appList.remove(w); - widgetMap.delete(key); - } + if (!widget) { + widget = createAppItem({ res: appInstance, monitorConnector }); + widgetMap.set(key, widget); + appList.append(widget); } - let prev: Gtk.Widget | null = null; - currentResults.forEach((res) => { - const key = getAppKey(res); - let w = widgetMap.get(key); - if (!w) { - w = createAppItem(res, monitorConnector); - widgetMap.set(key, w); - appList.append(w); + widget.set_visible(true); + appList.reorder_child_after(widget, previousWidget); + previousWidget = widget; + } +} + +function scrollToSelection(scrollWindow: Gtk.ScrolledWindow, targetChild: Gtk.Widget) { + const vadj = scrollWindow.get_vadjustment(); + const viewport = scrollWindow.get_child(); + if (!vadj || !viewport) return; + + const itemHeight = targetChild.get_height() || 50; + const position = targetChild.translate_coordinates(viewport, 0, 0); + if (!Array.isArray(position) || !position[0]) return; + + const visibleY = position[2]; + const visibleBottom = visibleY + itemHeight; + const pageSize = vadj.get_page_size(); + + if (visibleY < 0) { + vadj.set_value(vadj.get_value() + visibleY - 10); + } else if (visibleBottom > pageSize) { + vadj.set_value(vadj.get_value() + (visibleBottom - pageSize) + 10); + } +} + +function updateSelection( + appList: Gtk.Box, + searchGoogleBtn: Gtk.Button, + scrollWindow: Gtk.ScrolledWindow, + selectedIndex: number, + currentResults: Apps.Application[], + query: string, +) { + let targetChild: Gtk.Widget | null = null; + + let child = appList.get_first_child(); + let index = 0; + while (child) { + if (child.get_visible()) { + if (index === selectedIndex) { + child.add_css_class('selected'); + targetChild = child; + } else { + child.remove_css_class('selected'); } - w.set_visible(true); - appList.reorder_child_after(w, prev); - prev = w; - }); + index++; + } else { + child.remove_css_class('selected'); + } + child = child.get_next_sibling(); + } - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - updateSelection(); - return GLib.SOURCE_REMOVE; - }); + if (selectedIndex === currentResults.length && query !== '') { + searchGoogleBtn.add_css_class('selected'); + targetChild = searchGoogleBtn; + } else { + searchGoogleBtn.remove_css_class('selected'); } - const unsubscribeText = text.subscribe(() => populateApps()); + if (targetChild) scrollToSelection(scrollWindow, targetChild); +} - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - populateApps(); - return GLib.SOURCE_REMOVE; +export function AppList({ + text, + selectedIndex, + results, + setResults, + monitorConnector, +}: AppListProps): Gtk.ScrolledWindow { + const searchGoogleBtn = SearchGoogleBtn({ + textState: text, + monitorConnector, }); + const appList = () as Gtk.Box; + const widgetMap = new Map(); const scrollWindow = Object.assign(new Gtk.ScrolledWindow(), { cssClasses: ['applauncher-scroll'], hscrollbarPolicy: Gtk.PolicyType.NEVER, @@ -108,57 +155,44 @@ export function AppList({ ), }) as Gtk.ScrolledWindow; - function updateSelection() { - const idx = selectedIndex.get(); - let targetChild: Gtk.Widget | null = null; - - let child = appList.get_first_child(); - let i = 0; - while (child) { - if (child.get_visible()) { - if (i === idx) { - child.add_css_class('selected'); - targetChild = child; - } else { - child.remove_css_class('selected'); - } - i++; - } else { - child.remove_css_class('selected'); - } - child = child.get_next_sibling(); - } + function populateApps() { + const safeT = text.peek() || ''; + const q = safeT.trim().toLowerCase(); + const currentResults = getUniqueResults(q); - if (idx === currentResults.length && (text.get() || '').trim() !== '') { - searchGoogleBtn.add_css_class('selected'); - targetChild = searchGoogleBtn; - } else { - searchGoogleBtn.remove_css_class('selected'); - } + setResults(currentResults); + syncAppWidgets(appList, widgetMap, currentResults, monitorConnector); - if (targetChild) { - const vadj = scrollWindow.get_vadjustment(); - const viewport = scrollWindow.get_child(); - - if (vadj && viewport) { - const itemHeight = targetChild.get_height() || 50; - const res = targetChild.translate_coordinates(viewport, 0, 0); - if (Array.isArray(res) && res[0]) { - const visibleY = res[2]; - const visibleBottom = visibleY + itemHeight; - const pageSize = vadj.get_page_size(); - - if (visibleY < 0) { - vadj.set_value(vadj.get_value() + visibleY - 10); - } else if (visibleBottom > pageSize) { - vadj.set_value(vadj.get_value() + (visibleBottom - pageSize) + 10); - } - } - } - } + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + updateSelection( + appList, + searchGoogleBtn, + scrollWindow, + selectedIndex.peek(), + currentResults, + q, + ); + return GLib.SOURCE_REMOVE; + }); } - const unsubscribeSelection = selectedIndex.subscribe(() => updateSelection()); + const unsubscribeText = text.subscribe(() => populateApps()); + + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + populateApps(); + return GLib.SOURCE_REMOVE; + }); + + const unsubscribeSelection = selectedIndex.subscribe(() => { + updateSelection( + appList, + searchGoogleBtn, + scrollWindow, + selectedIndex.peek(), + results.peek(), + text.peek().trim(), + ); + }); scrollWindow.connect('destroy', () => { unsubscribeText(); diff --git a/src/widget/app-launcher/widget/SearchGoogleBtn.tsx b/src/widget/app-launcher/widget/SearchGoogleBtn.tsx index 2dd5b75..6c9512e 100644 --- a/src/widget/app-launcher/widget/SearchGoogleBtn.tsx +++ b/src/widget/app-launcher/widget/SearchGoogleBtn.tsx @@ -1,3 +1,4 @@ +import type { Accessor } from 'ags'; import { Gtk } from 'ags/gtk4'; import Pango from 'gi://Pango'; @@ -5,19 +6,12 @@ import Pango from 'gi://Pango'; import { getDirectUrl, openQuery } from '../../../stores/application'; import { toggleAppLauncher } from '../../../stores/windowManager'; -interface TextState { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - as: (cb: (t: string) => boolean | string) => any; - peek: () => string | undefined; +export interface SearchGoogleBtnProps { + textState: Accessor; + monitorConnector: string | null; } -export function SearchGoogleBtn({ - textState, - monitorConnector, -}: { - textState: TextState; - monitorConnector: string | null; -}) { +export function SearchGoogleBtn({ textState, monitorConnector }: SearchGoogleBtnProps): Gtk.Button { return ( diff --git a/src/widget/bar/widget/Workspaces.tsx b/src/widget/bar/widget/Workspaces.tsx index 77eeb0c..0e3a231 100644 --- a/src/widget/bar/widget/Workspaces.tsx +++ b/src/widget/bar/widget/Workspaces.tsx @@ -1,67 +1,40 @@ -import { For, createBinding as bind, createState } from 'ags'; +import { For } from 'ags'; import { Gdk, Gtk } from 'ags/gtk4'; import Hyprland from 'gi://AstalHyprland'; -export default function Workspaces({ gdkmonitor }: { gdkmonitor: Gdk.Monitor }) { - const hypr = Hyprland.get_default(); - const connector = gdkmonitor.get_connector(); +import { createMonitorWorkspaceState, focusWorkspace } from '../../../stores/workspace'; - const [workspaces, setWorkspaces] = createState([]); - const [activeIdx, setActiveIdx] = createState(0); - - function updateWorkspaces() { - const wss = hypr - .get_workspaces() - .filter((ws) => ws.monitor && ws.monitor.name === connector) - .filter((ws) => !ws.name.startsWith('special')) - .sort((a, b) => a.id - b.id); - - setWorkspaces(wss); - - const fw = hypr.get_focused_workspace(); - const idx = wss.findIndex((ws) => ws.id === fw?.id); - setActiveIdx(idx >= 0 ? idx : 0); - } - - const hooks = [ - hypr.connect('workspace-added', updateWorkspaces), - hypr.connect('workspace-removed', updateWorkspaces), - hypr.connect('notify::focused-workspace', updateWorkspaces), - ]; - - updateWorkspaces(); +export interface WorkspacesProps { + monitor: Gdk.Monitor; +} - const indicator = ( - `margin-bottom: -14px; transform: translateY(${i * 22 - 2}px);`)} - /> +export default function Workspaces({ monitor }: WorkspacesProps) { + const { workspaces, activeIndex, focusedWorkspaceId } = createMonitorWorkspaceState( + monitor.get_connector(), ); return ( - hooks.forEach((h) => hypr.disconnect(h))} - > - {indicator} + + `margin-bottom: -14px; transform: translateY(${index * 22 - 2}px);`, + )} + /> - {(ws: Hyprland.Workspace) => ( + {(workspace: Hyprland.Workspace) => ( - ) as Gtk.Button; - - let lastPlay = 0; - const playSound = () => { - const now = Date.now(); - if (now - lastPlay > 100) { - lastPlay = now; - playVolumeSound(); - } - }; - - const scroll = new Gtk.EventControllerScroll({ - flags: Gtk.EventControllerScrollFlags.VERTICAL, - }); - scroll.connect('scroll', (_, dx, dy) => { - if (dy > 0) { - speaker.volume = Math.max(0, speaker.volume - 0.05); - playSound(); - } else if (dy < 0) { - speaker.volume = Math.min(1, speaker.volume + 0.05); - playSound(); - } - return true; - }); - btn.add_controller(scroll); - - return btn; +export interface VolumeProps { + monitor: Gdk.Monitor; } -export default function Volume({ gdkmonitor }: { gdkmonitor: Gdk.Monitor }) { - const audio = Wp.get_default().audio; - const [speaker, setSpeaker] = createState(audio.default_speaker ?? null); - +export default function Volume({ monitor }: VolumeProps) { return ( - { - const hook = audio.connect('notify::default-speaker', () => - setSpeaker(audio.default_speaker ?? null), - ); - self.connect('destroy', () => audio.disconnect(hook)); - }} - > - (value ? [value] : []))}> - {(endpoint: Wp.Endpoint) => } + + (speaker ? [speaker] : []))}> + {(speaker: Wp.Endpoint) => } ); diff --git a/src/widget/bar/widget/VolumeButton.tsx b/src/widget/bar/widget/VolumeButton.tsx new file mode 100644 index 0000000..45366ad --- /dev/null +++ b/src/widget/bar/widget/VolumeButton.tsx @@ -0,0 +1,47 @@ +import { createBinding } from 'ags'; +import { Gdk, Gtk } from 'ags/gtk4'; + +import Wp from 'gi://AstalWp'; + +import { adjustVolume } from '../../../stores/audio'; +import { toggleControlCenter } from '../../../stores/windowManager'; +import { LucideIcon } from '../../../widget/common/lucide'; + +export interface VolumeButtonProps { + speaker: Wp.Endpoint; + monitor: Gdk.Monitor; +} + +function getVolumeIcon(icon: string) { + if (icon.includes('muted')) return 'volume-x'; + if (icon.includes('high')) return 'volume-2'; + if (icon.includes('medium')) return 'volume-1'; + if (icon.includes('low')) return 'volume'; + return 'volume-x'; +} + +export function VolumeButton({ speaker, monitor }: VolumeButtonProps) { + const volumeIcon = createBinding(speaker, 'volume_icon').as(getVolumeIcon); + + return ( + + ); +} From aadec1e37b4a94571db92a47b962cd7c16f78c79 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:32:29 +0900 Subject: [PATCH 09/85] refactor: simplify bar tray lifecycle --- src/widget/bar/widget/Tray.tsx | 64 ++---------------------- src/widget/bar/widget/TrayItemButton.tsx | 56 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 60 deletions(-) create mode 100644 src/widget/bar/widget/TrayItemButton.tsx diff --git a/src/widget/bar/widget/Tray.tsx b/src/widget/bar/widget/Tray.tsx index 18d2aaf..d236496 100644 --- a/src/widget/bar/widget/Tray.tsx +++ b/src/widget/bar/widget/Tray.tsx @@ -1,70 +1,14 @@ -import { For, createBinding } from 'ags'; +import { For, createBinding, onCleanup } from 'ags'; import { Gtk } from 'ags/gtk4'; import AstalTray from 'gi://AstalTray'; +import { TrayItemButton } from './TrayItemButton'; + function isFcitxItem(item: AstalTray.TrayItem) { return item.id.toLowerCase().includes('fcitx'); } -function TrayItemButton({ - item, - onActivate, -}: { - item: AstalTray.TrayItem; - onActivate: () => void; -}) { - const button = ( - - - - ) as Gtk.MenuButton; - - const menuModel = item.menu_model; - const menu = menuModel - ? Gtk.PopoverMenu.new_from_model_full(menuModel, Gtk.PopoverMenuFlags.NESTED) - : new Gtk.Popover({ - child: ( - ) as Gtk.Widget } @@ -102,8 +102,8 @@ export default function Bar(gdkmonitor: Gdk.Monitor) { spacing={8} > - - + + From 9010b48d996f5a47566bf2cda0cfb6fe34116e40 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:57:01 +0900 Subject: [PATCH 11/85] refactor: move bar background lifecycle into store --- knip.json | 2 +- src/stores/barBackground.ts | 130 +++++++++++++ src/widget/bar/PanelBackground.tsx | 295 ++++++++++------------------- src/widget/bar/index.tsx | 2 +- 4 files changed, 233 insertions(+), 196 deletions(-) create mode 100644 src/stores/barBackground.ts diff --git a/knip.json b/knip.json index fe44e95..f1ead66 100644 --- a/knip.json +++ b/knip.json @@ -2,6 +2,6 @@ "$schema": "https://unpkg.com/knip@6/schema.json", "tags": ["-lintignore"], "entry": ["src/app.ts"], - "ignoreDependencies": ["gi", "system"], + "ignoreDependencies": ["cairo", "gi", "system"], "ignoreBinaries": ["ags"] } diff --git a/src/stores/barBackground.ts b/src/stores/barBackground.ts new file mode 100644 index 0000000..e3134f0 --- /dev/null +++ b/src/stores/barBackground.ts @@ -0,0 +1,130 @@ +import { type Accessor, createExternal, createState } from 'ags'; +import { type Timer, interval } from 'ags/time'; + +import GLib from 'gi://GLib'; + +import { rystalShellConfigDir, rystalShellDataDir } from '../lib/paths'; +import { activeSidePanel, setAnimBottomHeight, setAnimDx } from './windowManager'; + +export interface BarColors { + surface: string; + primary: string; +} + +export interface BarBackgroundGeometry { + dx: number; + bottomHeight: number; +} + +const BAR_WIDTH = 47; +const CONTROL_CENTER_WIDTH = 490; +const DATE_WEATHER_WIDTH = 900; +const WALLPAPER_PANEL_HEIGHT = 390; +const POWER_MENU_PANEL_HEIGHT = 350; +const ANIMATION_INTERVAL_MS = 1000 / 60; +const ANIMATION_SPEED = 0.22; +const configuredThemePath = `${rystalShellConfigDir}/theme.scss`; +const defaultThemePath = `${rystalShellDataDir}/styles/default/theme.scss`; + +function readBarColors(): BarColors { + try { + const themePath = GLib.file_test(configuredThemePath, GLib.FileTest.EXISTS) + ? configuredThemePath + : defaultThemePath; + const [success, bytes] = GLib.file_get_contents(themePath); + if (!success || !bytes) throw new Error(`Cannot read bar colors: ${themePath}`); + + const contents = new TextDecoder().decode(bytes); + let surface = '#191114'; + let primary = '#ffb0ce'; + + for (const line of contents.split('\n')) { + const surfaceMatch = line.trim().match(/^\$surface:\s*(#[0-9a-fA-F]{6})/); + const primaryMatch = line.trim().match(/^\$primary:\s*(#[0-9a-fA-F]{6})/); + if (surfaceMatch) surface = surfaceMatch[1]; + if (primaryMatch) primary = primaryMatch[1]; + } + + return { surface, primary }; + } catch (error) { + console.error(`Failed to read bar colors: ${error}`); + return { surface: '#191114', primary: '#ffb0ce' }; + } +} + +function getTargetGeometry(panel: string, isTargetMonitor: boolean): BarBackgroundGeometry { + if (!isTargetMonitor) return { dx: BAR_WIDTH, bottomHeight: 0 }; + + const dx = + panel === 'control-center' + ? BAR_WIDTH + CONTROL_CENTER_WIDTH + : panel === 'date-weather' + ? BAR_WIDTH + DATE_WEATHER_WIDTH + : BAR_WIDTH; + const bottomHeight = + panel === 'wallpaper-selector' + ? WALLPAPER_PANEL_HEIGHT + : panel === 'power-menu' + ? POWER_MENU_PANEL_HEIGHT + : 0; + + return { dx, bottomHeight }; +} + +const [barColorsState, setBarColors] = createState(readBarColors()); +export const barColors = barColorsState; + +export function reloadBarColors() { + setBarColors(readBarColors()); +} + +export function createBarBackgroundGeometry( + monitorConnector: string | null, +): Accessor { + const initialGeometry = { dx: BAR_WIDTH, bottomHeight: 0 }; + + return createExternal(initialGeometry, (setGeometry) => { + let currentGeometry = initialGeometry; + let targetGeometry = initialGeometry; + let animationTimer: Timer | null = null; + + const publishGeometry = (geometry: BarBackgroundGeometry) => { + const activeMonitor = activeSidePanel.get().monitor; + if (activeMonitor === monitorConnector || activeMonitor === '') { + setAnimDx(geometry.dx); + setAnimBottomHeight(geometry.bottomHeight); + } + setGeometry(geometry); + }; + + const animate = () => { + const horizontalDiff = targetGeometry.dx - currentGeometry.dx; + const bottomDiff = targetGeometry.bottomHeight - currentGeometry.bottomHeight; + + if (Math.abs(horizontalDiff) < 1 && Math.abs(bottomDiff) < 1) { + currentGeometry = targetGeometry; + publishGeometry(currentGeometry); + animationTimer?.cancel(); + animationTimer = null; + return; + } + + currentGeometry = { + dx: currentGeometry.dx + horizontalDiff * ANIMATION_SPEED, + bottomHeight: currentGeometry.bottomHeight + bottomDiff * ANIMATION_SPEED, + }; + publishGeometry(currentGeometry); + }; + + const unsubscribePanel = activeSidePanel.subscribe(({ panel, monitor }) => { + targetGeometry = getTargetGeometry(panel, monitor === monitorConnector); + animationTimer ??= interval(ANIMATION_INTERVAL_MS, animate); + }); + + return () => { + unsubscribePanel(); + animationTimer?.cancel(); + animationTimer = null; + }; + }); +} diff --git a/src/widget/bar/PanelBackground.tsx b/src/widget/bar/PanelBackground.tsx index a65ea07..3590b72 100644 --- a/src/widget/bar/PanelBackground.tsx +++ b/src/widget/bar/PanelBackground.tsx @@ -1,216 +1,123 @@ -import { Gdk, Gtk } from 'ags/gtk4'; - -import GLib from 'gi://GLib'; -import Cairo from 'gi://cairo'; - -import { rystalShellConfigDir, rystalShellDataDir } from '../../lib/paths'; -import { activeSidePanel, setAnimBottomHeight, setAnimDx } from '../../stores/windowManager'; - -const BORDER_WIDTH = 3; -const BAR_WIDTH = 47; -const WALLPAPER_PANEL_HEIGHT = 390; -const POWER_MENU_PANEL_HEIGHT = 350; -const configuredThemePath = `${rystalShellConfigDir}/theme.scss`; -const defaultThemePath = `${rystalShellDataDir}/styles/default/theme.scss`; +import Cairo from 'cairo'; -// --- Color parsing --- -function hexToRgba(hex: string): [number, number, number, number] { - hex = hex.replace('#', ''); - if (hex.length === 6) { - return [ - parseInt(hex.slice(0, 2), 16) / 255, - parseInt(hex.slice(2, 4), 16) / 255, - parseInt(hex.slice(4, 6), 16) / 255, - 1, - ]; - } - if (hex.length === 8) { - return [ - parseInt(hex.slice(0, 2), 16) / 255, - parseInt(hex.slice(2, 4), 16) / 255, - parseInt(hex.slice(4, 6), 16) / 255, - parseInt(hex.slice(6, 8), 16) / 255, - ]; - } - return [0.1, 0.07, 0.08, 1]; -} - -function readMatugenColors(): { surface: string; primary: string } { - try { - const themePath = GLib.file_test(configuredThemePath, GLib.FileTest.EXISTS) - ? configuredThemePath - : defaultThemePath; - const [ok, bytes] = GLib.file_get_contents(themePath); - if (!ok || !bytes) throw new Error('read failed'); - const contents = new TextDecoder().decode(bytes); +import { createEffect } from 'ags'; +import { Gdk, Gtk } from 'ags/gtk4'; - let surface = '#191114'; - let primary = '#ffb0ce'; +import { + type BarBackgroundGeometry, + type BarColors, + barColors, + createBarBackgroundGeometry, + reloadBarColors, +} from '../../stores/barBackground'; - for (const line of contents.split('\n')) { - const trimmed = line.trim(); - const sm = trimmed.match(/^\$surface:\s*(#[0-9a-fA-F]{6})/); - if (sm) surface = sm[1]; - const pm = trimmed.match(/^\$primary:\s*(#[0-9a-fA-F]{6})/); - if (pm) primary = pm[1]; - } - return { surface, primary }; - } catch (e) { - console.error(`[Bar] Failed to read matugen.scss: ${e}`); - return { surface: '#191114', primary: '#ffb0ce' }; - } +export interface PanelBackgroundProps { + monitor: Gdk.Monitor; } -// --- Module-level state (survives GC) --- -let currentColors = readMatugenColors(); -const drawingAreas: Gtk.DrawingArea[] = []; +const BORDER_WIDTH = 3; +const BORDER_RADIUS = 16; -export function forceRedrawBar() { - currentColors = readMatugenColors(); - for (const da of drawingAreas) { - da.queue_draw(); - } -} +export { reloadBarColors as forceRedrawBar }; -function getBgRgba(): [number, number, number, number] { - const [r, g, b] = hexToRgba(currentColors.surface); - return [r, g, b, 0.75]; +function hexToRgba(hex: string): [number, number, number, number] { + const value = hex.replace('#', ''); + if (value.length !== 6 && value.length !== 8) return [0.1, 0.07, 0.08, 1]; + + return [ + parseInt(value.slice(0, 2), 16) / 255, + parseInt(value.slice(2, 4), 16) / 255, + parseInt(value.slice(4, 6), 16) / 255, + parseInt(value.slice(6, 8) || 'ff', 16) / 255, + ]; } -function getAccentRgba(): [number, number, number, number] { - return hexToRgba(currentColors.primary); +function drawBackground( + context: Cairo.Context, + width: number, + height: number, + geometry: BarBackgroundGeometry, + colors: BarColors, +) { + const [backgroundRed, backgroundGreen, backgroundBlue] = hexToRgba(colors.surface); + const [accentRed, accentGreen, accentBlue, accentAlpha] = hexToRgba(colors.primary); + const halfBorderWidth = BORDER_WIDTH / 2; + const desktopX = geometry.dx + halfBorderWidth; + const desktopY = halfBorderWidth; + const desktopWidth = width - geometry.dx - BORDER_WIDTH; + const desktopHeight = height - geometry.bottomHeight - BORDER_WIDTH; + + context.setAntialias(Cairo.Antialias.BEST); + context.setOperator(Cairo.Operator.OVER); + context.setSourceRGBA(backgroundRed, backgroundGreen, backgroundBlue, 0.75); + context.rectangle(0, 0, width, height); + context.fill(); + + context.newPath(); + context.arc( + desktopX + desktopWidth - BORDER_RADIUS, + desktopY + BORDER_RADIUS, + BORDER_RADIUS, + -Math.PI / 2, + 0, + ); + context.arc( + desktopX + desktopWidth - BORDER_RADIUS, + desktopY + desktopHeight - BORDER_RADIUS, + BORDER_RADIUS, + 0, + Math.PI / 2, + ); + context.arc( + desktopX + BORDER_RADIUS, + desktopY + desktopHeight - BORDER_RADIUS, + BORDER_RADIUS, + Math.PI / 2, + Math.PI, + ); + context.arc( + desktopX + BORDER_RADIUS, + desktopY + BORDER_RADIUS, + BORDER_RADIUS, + Math.PI, + (3 * Math.PI) / 2, + ); + context.closePath(); + + context.setOperator(Cairo.Operator.CLEAR); + context.fillPreserve(); + context.setOperator(Cairo.Operator.OVER); + context.setSourceRGBA(accentRed, accentGreen, accentBlue, accentAlpha); + context.setLineWidth(BORDER_WIDTH); + context.stroke(); + context.$dispose(); } -export default function PanelBackground({ gdkmonitor }: { gdkmonitor: Gdk.Monitor }) { - const monitorConnector = gdkmonitor.get_connector(); - let targetDx = BAR_WIDTH; - let currentDx = BAR_WIDTH; - let targetBottomHeight = 0; - let currentBottomHeight = 0; - let animTickId = 0; - - const publishPanelSize = (dx: number, bottomHeight: number) => { - const activeMonitor = activeSidePanel.get().monitor; - // Popup windows consume these shared values. During a monitor switch, the - // old monitor still animates closed but must not overwrite the new popup's - // opening animation. - if (activeMonitor === monitorConnector || activeMonitor === '') { - setAnimDx(dx); - setAnimBottomHeight(bottomHeight); - } - }; +export default function PanelBackground({ monitor }: PanelBackgroundProps) { + const geometry = createBarBackgroundGeometry(monitor.get_connector()); + let drawingArea!: Gtk.DrawingArea; - const unsubscribePanel = activeSidePanel.subscribe(({ panel, monitor }) => { - const isTargetMonitor = monitor === monitorConnector; - if (isTargetMonitor) { - if (panel === 'control-center') { - targetDx = BAR_WIDTH + 490; - } else if (panel === 'date-weather') { - targetDx = BAR_WIDTH + 900; - } else { - targetDx = BAR_WIDTH; - } - if (panel === 'wallpaper-selector') { - targetBottomHeight = WALLPAPER_PANEL_HEIGHT; - } else if (panel === 'power-menu') { - targetBottomHeight = POWER_MENU_PANEL_HEIGHT; - } else { - targetBottomHeight = 0; - } - } else { - // A panel opened on another monitor must also collapse this monitor's - // previously expanded bar background. - targetDx = BAR_WIDTH; - targetBottomHeight = 0; - } - - if (animTickId === 0) { - animTickId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000 / 60, () => { - const horizontalDiff = targetDx - currentDx; - const bottomDiff = targetBottomHeight - currentBottomHeight; - if (Math.abs(horizontalDiff) < 1.0 && Math.abs(bottomDiff) < 1.0) { - currentDx = targetDx; - currentBottomHeight = targetBottomHeight; - publishPanelSize(targetDx, targetBottomHeight); - animTickId = 0; - for (const da of drawingAreas) da.queue_draw(); - return GLib.SOURCE_REMOVE; - } - // Simple ease-out - const speed = 0.22; - - currentDx += horizontalDiff * speed; - currentBottomHeight += bottomDiff * speed; - publishPanelSize(currentDx, currentBottomHeight); - for (const da of drawingAreas) da.queue_draw(); - return GLib.SOURCE_CONTINUE; - }); - } - }); - - return ( + const widget = ( { - unsubscribePanel(); - if (animTickId !== 0) { - GLib.source_remove(animTickId); - animTickId = 0; - } - const idx = drawingAreas.indexOf(da as Gtk.DrawingArea); - if (idx !== -1) drawingAreas.splice(idx, 1); - }} - $={(da: Gtk.DrawingArea) => { - drawingAreas.push(da); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - da.set_draw_func((_area, ctx: any, w: number, h: number) => { - const [bgR, bgG, bgB, bgA] = getBgRgba(); - const [bR, bG, bB, bA] = getAccentRgba(); - - ctx.setAntialias(Cairo.Antialias.BEST); - - const bw = BORDER_WIDTH; - const halfBw = bw / 2.0; - const r = 16; // border radius - - // Desktop area rectangle (inset by half border width so stroke is fully visible) - const dx = currentDx + halfBw; - const dy = halfBw; - const dw = w - currentDx - bw; - const dh = h - currentBottomHeight - bw; - - // 1. Fill entire screen with background color - ctx.setOperator(Cairo.Operator.OVER); - ctx.setSourceRGBA(bgR, bgG, bgB, bgA); - ctx.rectangle(0, 0, w, h); - ctx.fill(); - - // Path for desktop hole - ctx.newPath(); - ctx.arc(dx + dw - r, dy + r, r, -Math.PI / 2, 0); // Top-right corner - ctx.arc(dx + dw - r, dy + dh - r, r, 0, Math.PI / 2); // Bottom-right corner - ctx.arc(dx + r, dy + dh - r, r, Math.PI / 2, Math.PI); // Bottom-left corner - ctx.arc(dx + r, dy + r, r, Math.PI, (3 * Math.PI) / 2); // Top-left corner - ctx.closePath(); - - // 2. Clear the desktop hole to show wallpaper/windows underneath - ctx.setOperator(Cairo.Operator.CLEAR); - ctx.fillPreserve(); - - // 3. Draw the accent border along the path - ctx.setOperator(Cairo.Operator.OVER); - ctx.setSourceRGBA(bR, bG, bB, bA); - ctx.setLineWidth(bw); - ctx.stroke(); - - // 4. Dispose of the Cairo context to prevent GJS memory leak - ctx.$dispose(); + $={(self) => { + drawingArea = self; + self.set_draw_func((_area, context, width, height) => { + drawBackground(context, width, height, geometry.peek(), barColors.peek()); }); }} /> - ); + ) as Gtk.DrawingArea; + + createEffect(() => { + geometry(); + barColors(); + drawingArea.queue_draw(); + }); + + return widget; } diff --git a/src/widget/bar/index.tsx b/src/widget/bar/index.tsx index 7edd15f..ce3ba50 100644 --- a/src/widget/bar/index.tsx +++ b/src/widget/bar/index.tsx @@ -115,7 +115,7 @@ export default function Bar(gdkmonitor: Gdk.Monitor) { ); }} > - + ); From 5f12802f294bf235fe4798c0cbf0ecb6be64ad0f Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:57:17 +0900 Subject: [PATCH 12/85] refactor: simplify bar window composition --- src/app.ts | 2 +- src/widget/bar/BarReserve.tsx | 10 ++- src/widget/bar/index.tsx | 165 ++++++++++++++++------------------ 3 files changed, 84 insertions(+), 93 deletions(-) diff --git a/src/app.ts b/src/app.ts index df3dece..4716663 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,7 +26,7 @@ app.start({ } app.get_monitors().forEach((m) => { - Bar(m); + Bar({ monitor: m }); ControlCenter(m); WifiPasswordDialog(m); DateWeatherPopup(m); diff --git a/src/widget/bar/BarReserve.tsx b/src/widget/bar/BarReserve.tsx index 8d9e290..e07418f 100644 --- a/src/widget/bar/BarReserve.tsx +++ b/src/widget/bar/BarReserve.tsx @@ -4,15 +4,19 @@ import app from 'ags/gtk4/app'; const BORDER_WIDTH = 3; const BAR_WIDTH = 47; -export default function BarReserve(gdkmonitor: Gdk.Monitor) { +export interface BarReserveProps { + monitor: Gdk.Monitor; +} + +export default function BarReserve({ monitor }: BarReserveProps) { const { TOP, BOTTOM, LEFT } = Astal.WindowAnchor; return ( { - GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { - const surf = self.get_native()?.get_surface(); - if (surf) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const Region = (Cairo as any).Region; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const RectangleInt = (Cairo as any).RectangleInt; - const region = new Region(); - region.unionRectangle( - new RectangleInt({ x: 0, y: 0, width: BAR_WIDTH + BORDER_WIDTH, height: 9999 }), - ); - surf.set_input_region(region); - } - return GLib.SOURCE_REMOVE; - }); - }} > - { - overlay.add_overlay( - ( - - - - - - ) as Gtk.Widget - } - centerWidget={ - ( - - - - - ) as Gtk.Widget - } - endWidget={ - ( - - - - - - - - ) as Gtk.Widget - } - /> - - ) as Gtk.Widget, - ); - }} - > - + + + + + + + + + + + + + + + + + + + + + - ); + ) as Astal.Window; + + const inputRegionTimer = timeout(INPUT_REGION_DELAY_MS, () => setBarInputRegion(window)); + onCleanup(() => inputRegionTimer.cancel()); + + return window; } From 03c9a8c959deac34543bf8bd1ca3abefd186bbd4 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:17:33 +0900 Subject: [PATCH 13/85] refactor: type lucide icon props --- src/widget/common/lucide.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/widget/common/lucide.tsx b/src/widget/common/lucide.tsx index b2f56cf..63a486f 100644 --- a/src/widget/common/lucide.tsx +++ b/src/widget/common/lucide.tsx @@ -1,10 +1,23 @@ +import { Accessor, type CCProps } from 'ags'; +import { Gtk } from 'ags/gtk4'; + +type ImageProps = Omit< + CCProps>, + 'children' | 'iconName' | 'name' +>; + +export interface LucideIconProps extends ImageProps { + name: string | Accessor; + class?: string | Accessor; + css?: string | Accessor; +} + /** * Helper function to get the symbolic icon name for a lucide icon. * @param name The icon name (e.g., "activity", "check-circle") * @returns The symbolic icon name */ function lucideIcon(name: string): string { - // Prefix with 'lucide-' to prevent collisions with system GTK icons like Adwaita return `lucide-${name}-symbolic`; } @@ -12,12 +25,8 @@ function lucideIcon(name: string): string { * LucideIcon component that correctly utilizes GTK's IconTheme via iconName. * This allows SCSS states (like :hover, color changes) and dynamic binding updates to work perfectly. */ -export function LucideIcon({ name, ...props }: { name: string | unknown; [key: string]: unknown }) { - // Check if name is a reactive binding/accessor (has an .as method) - const icon = - name && typeof (name as { as?: (...args: unknown[]) => unknown }).as === 'function' - ? (name as { as: (fn: (n: string) => string) => string }).as((n: string) => lucideIcon(n)) - : lucideIcon(name as string); +export function LucideIcon({ name, ...props }: LucideIconProps) { + const icon = name instanceof Accessor ? name.as(lucideIcon) : lucideIcon(name); return ; } From 9065125d31f1327be4e6160f129086d8c62fcba7 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:17:40 +0900 Subject: [PATCH 14/85] refactor: move animated list lifecycle into store --- src/stores/animatedList.ts | 85 ++++++++++++++ src/widget/common/AnimatedList.tsx | 110 +++--------------- .../date-weather/widget/NotificationList.tsx | 14 +-- 3 files changed, 105 insertions(+), 104 deletions(-) create mode 100644 src/stores/animatedList.ts diff --git a/src/stores/animatedList.ts b/src/stores/animatedList.ts new file mode 100644 index 0000000..d49e4f9 --- /dev/null +++ b/src/stores/animatedList.ts @@ -0,0 +1,85 @@ +import { type Accessor, type Setter, createState, onCleanup } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import { shellMotion } from '../lib/motion'; + +export interface AnimatedListEntry { + id: string; + item: T; + revealed: Accessor; + setRevealed: Setter; +} + +function cancelTimer(timers: Map, id: string) { + timers.get(id)?.cancel(); + timers.delete(id); +} + +export function createAnimatedListEntries( + items: Accessor, + idFor: (item: T) => string, +): Accessor[]> { + const [entries, setEntries] = createState[]>([]); + const revealTimers = new Map(); + const removeTimers = new Map(); + + const createEntry = (item: T): AnimatedListEntry => { + const [revealed, setRevealed] = createState(false); + const id = idFor(item); + const timer = timeout(shellMotion.listRevealDelay, () => { + setRevealed(true); + revealTimers.delete(id); + }); + revealTimers.set(id, timer); + return { id, item, revealed, setRevealed }; + }; + + const scheduleRemoval = (entry: AnimatedListEntry) => { + cancelTimer(revealTimers, entry.id); + entry.setRevealed(false); + const timer = timeout(shellMotion.listDuration, () => { + setEntries(entries.peek().filter((candidate) => candidate.id !== entry.id)); + removeTimers.delete(entry.id); + }); + removeTimers.set(entry.id, timer); + }; + + const updateEntries = () => { + const nextItems = items.peek(); + const previousEntries = entries.peek(); + const previousById = new Map(previousEntries.map((entry) => [entry.id, entry])); + const nextIds = new Set(nextItems.map(idFor)); + + const nextEntries = nextItems.map((item) => { + const id = idFor(item); + const previousEntry = previousById.get(id); + if (!previousEntry) return createEntry(item); + + previousEntry.item = item; + if (removeTimers.has(id)) { + cancelTimer(removeTimers, id); + previousEntry.setRevealed(true); + } + return previousEntry; + }); + + for (const entry of previousEntries) { + if (!nextIds.has(entry.id) && !removeTimers.has(entry.id)) scheduleRemoval(entry); + } + + setEntries([...nextEntries, ...previousEntries.filter((entry) => !nextIds.has(entry.id))]); + }; + + const unsubscribe = items.subscribe(updateEntries); + updateEntries(); + + onCleanup(() => { + unsubscribe(); + for (const timer of revealTimers.values()) timer.cancel(); + for (const timer of removeTimers.values()) timer.cancel(); + revealTimers.clear(); + removeTimers.clear(); + }); + + return entries; +} diff --git a/src/widget/common/AnimatedList.tsx b/src/widget/common/AnimatedList.tsx index 678442b..23827b7 100644 --- a/src/widget/common/AnimatedList.tsx +++ b/src/widget/common/AnimatedList.tsx @@ -1,19 +1,16 @@ -import { For, createState } from 'ags'; +import { type Accessor, For } from 'ags'; import { Gtk } from 'ags/gtk4'; import { shellMotion } from '../../lib/motion'; +import { createAnimatedListEntries } from '../../stores/animatedList'; -type ListState = { - (): T[]; - subscribe: (callback: () => void) => () => void; -}; - -type Entry = { - id: string; - item: T; - revealed: ReturnType>[0]; - setRevealed: ReturnType>[1]; -}; +export interface AnimatedListProps { + items: Accessor; + idFor: (item: T) => string; + renderItem: (item: T) => JSX.Element; + className?: string; + spacing?: number; +} export default function AnimatedList({ items, @@ -21,92 +18,13 @@ export default function AnimatedList({ renderItem, className, spacing = 0, -}: { - items: ListState; - idFor: (item: T) => string; - renderItem: (item: T) => Gtk.Widget; - className?: string; - spacing?: number; -}) { - const [entries, setEntries] = createState[]>([]); - const revealTimers = new Map>(); - const removeTimers = new Map>(); - let destroyed = false; - - const cancelTimer = (timers: Map>, id: string) => { - const timer = timers.get(id); - if (timer) clearTimeout(timer); - timers.delete(id); - }; - - const createEntry = (item: T): Entry => { - const [revealed, setRevealed] = createState(false); - const id = idFor(item); - const timer = setTimeout(() => { - if (!destroyed) setRevealed(true); - revealTimers.delete(id); - }, shellMotion.listRevealDelay); - revealTimers.set(id, timer); - return { id, item, revealed, setRevealed }; - }; - - const scheduleRemoval = (entry: Entry) => { - entry.setRevealed(false); - const timer = setTimeout(() => { - if (!destroyed) setEntries(entries.peek().filter((candidate) => candidate.id !== entry.id)); - removeTimers.delete(entry.id); - }, shellMotion.listDuration); - removeTimers.set(entry.id, timer); - }; - - const updateEntries = () => { - const nextItems = items(); - const previous = entries.peek(); - const previousById = new Map(previous.map((entry) => [entry.id, entry])); - const nextIds = new Set(nextItems.map(idFor)); - - const nextEntries = nextItems.map((item) => { - const id = idFor(item); - const previousEntry = previousById.get(id); - if (!previousEntry) return createEntry(item); - - previousEntry.item = item; - if (removeTimers.has(id)) { - cancelTimer(removeTimers, id); - previousEntry.setRevealed(true); - } - return previousEntry; - }); - - const exitingEntries = previous.filter((entry) => !nextIds.has(entry.id)); - for (const entry of exitingEntries) { - if (!removeTimers.has(entry.id)) { - scheduleRemoval(entry); - } - } - - setEntries([...nextEntries, ...exitingEntries]); - }; +}: AnimatedListProps) { + const entries = createAnimatedListEntries(items, idFor); return ( - { - const unsubscribe = items.subscribe(updateEntries); - self.connect('destroy', () => { - destroyed = true; - unsubscribe(); - for (const timer of revealTimers.values()) clearTimeout(timer); - for (const timer of removeTimers.values()) clearTimeout(timer); - revealTimers.clear(); - removeTimers.clear(); - }); - }} - > - - {(entry: Entry) => ( + + entry.id}> + {(entry) => ( String(notification.id)} className="notif-list" spacing={12} - renderItem={(notification: Notifd.Notification) => - ( - dismissNotification(notification)} - /> - ) as unknown as Gtk.Widget - } + renderItem={(notification: Notifd.Notification) => ( + dismissNotification(notification)} + /> + )} /> From fad89e99f381ac1305349fab147cc35fe4933e3a Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:17:48 +0900 Subject: [PATCH 15/85] refactor: encapsulate circular progress animation --- src/widget/common/CircularProgress.tsx | 280 +++++++++++++------------ 1 file changed, 151 insertions(+), 129 deletions(-) diff --git a/src/widget/common/CircularProgress.tsx b/src/widget/common/CircularProgress.tsx index 1a8c9ec..b012d29 100644 --- a/src/widget/common/CircularProgress.tsx +++ b/src/widget/common/CircularProgress.tsx @@ -1,154 +1,176 @@ +import type Cairo from 'cairo'; + +import { type Accessor, onCleanup } from 'ags'; import { Gtk } from 'ags/gtk4'; +import { type Timer, interval } from 'ags/time'; import GLib from 'gi://GLib'; import { shellMotion } from '../../lib/motion'; -import { LucideIcon } from '../../widget/common/lucide'; +import { LucideIcon } from './lucide'; -export default function CircularProgress({ - variable, - transformer, - icon, - label, - sublabel, - cssClass, -}: { - variable: { get: () => T; subscribe: (fn: () => void) => () => void }; - transformer: (v: T) => number; +export interface CircularProgressProps { + variable: Accessor; + transformer: (value: T) => number; icon: string; label: string; - sublabel: string | unknown; + sublabel: string | Accessor; cssClass: string; -}) { - const area = new Gtk.DrawingArea(); - area.add_css_class(cssClass); - area.set_content_width(120); - area.set_content_height(120); - area.set_size_request(120, 120); - - const normalize = (value: number) => - Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; - - let currentValue = normalize(transformer(variable.get())); - let startValue = currentValue; - let targetValue = currentValue; - let animationStartedAt = 0; - let animationSourceId = 0; - - const animate = () => { - const elapsed = (GLib.get_monotonic_time() - animationStartedAt) / 1000; +} + +const ANIMATION_INTERVAL_MS = 1000 / 60; +const LINE_WIDTH = 6; + +function normalize(value: number) { + return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; +} + +class CircularProgressAnimation { + private currentValue: number; + private startValue: number; + private targetValue: number; + private animationStartedAt = 0; + private animationTimer: Timer | null = null; + private readonly unsubscribe: () => void; + private readonly mapSignal: number; + private readonly unmapSignal: number; + + constructor( + private readonly area: Gtk.DrawingArea, + private readonly variable: Accessor, + private readonly transformer: (value: T) => number, + ) { + this.currentValue = normalize(transformer(variable.peek())); + this.startValue = this.currentValue; + this.targetValue = this.currentValue; + this.unsubscribe = variable.subscribe(() => this.updateValue()); + this.mapSignal = area.connect('map', () => this.handleMap()); + this.unmapSignal = area.connect('unmap', () => this.handleUnmap()); + area.set_draw_func((_area, context, width, height) => this.draw(context, width, height)); + } + + private animate() { + const elapsed = (GLib.get_monotonic_time() - this.animationStartedAt) / 1000; const progress = Math.min(1, elapsed / shellMotion.metricDuration); const eased = 1 - Math.pow(1 - progress, 3); - currentValue = startValue + (targetValue - startValue) * eased; - area.queue_draw(); + this.currentValue = this.startValue + (this.targetValue - this.startValue) * eased; + this.area.queue_draw(); if (progress === 1) { - animationSourceId = 0; - return GLib.SOURCE_REMOVE; + this.animationTimer?.cancel(); + this.animationTimer = null; } - return GLib.SOURCE_CONTINUE; - }; - - const updateValue = () => { - targetValue = normalize(transformer(variable.get())); - - if (!area.get_mapped()) { - if (animationSourceId !== 0) { - GLib.source_remove(animationSourceId); - animationSourceId = 0; - } - currentValue = targetValue; - startValue = targetValue; + } + + private updateValue() { + this.targetValue = normalize(this.transformer(this.variable.peek())); + + if (!this.area.get_mapped()) { + this.stopAnimation(); + this.syncCurrentValue(); return; } - startValue = currentValue; - animationStartedAt = GLib.get_monotonic_time(); + this.startValue = this.currentValue; + this.animationStartedAt = GLib.get_monotonic_time(); + this.animationTimer ??= interval(ANIMATION_INTERVAL_MS, () => this.animate()); + } + + private syncCurrentValue() { + this.currentValue = this.targetValue; + this.startValue = this.targetValue; + } + + private stopAnimation() { + this.animationTimer?.cancel(); + this.animationTimer = null; + } + + private handleMap() { + this.syncCurrentValue(); + this.area.queue_draw(); + } + + private handleUnmap() { + this.stopAnimation(); + this.syncCurrentValue(); + } + + private draw(context: Cairo.Context, width: number, height: number) { + const color = this.area.get_style_context().get_color(); + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) / 2 - LINE_WIDTH; + + context.setSourceRGBA(color.red, color.green, color.blue, 0.15); + context.setLineWidth(LINE_WIDTH); + context.arc(centerX, centerY, radius, 0, 2 * Math.PI); + context.stroke(); + + if (this.currentValue <= 0) return; + + context.setSourceRGBA(color.red, color.green, color.blue, 1); + context.setLineWidth(LINE_WIDTH); + context.setLineCap(1); + context.arc( + centerX, + centerY, + radius, + 1.5 * Math.PI, + 1.5 * Math.PI + this.currentValue * 2 * Math.PI, + ); + context.stroke(); + } + + dispose() { + this.unsubscribe(); + this.stopAnimation(); + this.area.disconnect(this.mapSignal); + this.area.disconnect(this.unmapSignal); + this.area.set_draw_func(null); + } +} - if (animationSourceId === 0) { - animationSourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000 / 60, animate); - } - }; - const unsubscribe = variable.subscribe(updateValue); - - const syncCurrentValue = () => { - currentValue = targetValue; - startValue = targetValue; - }; - - const mapSignalId = area.connect('map', () => { - syncCurrentValue(); - area.queue_draw(); - }); - const unmapSignalId = area.connect('unmap', () => { - if (animationSourceId !== 0) { - GLib.source_remove(animationSourceId); - animationSourceId = 0; - } - syncCurrentValue(); - }); - - area.connect('destroy', () => { - unsubscribe(); - if (animationSourceId !== 0) { - GLib.source_remove(animationSourceId); - animationSourceId = 0; - } - area.disconnect(mapSignalId); - area.disconnect(unmapSignalId); - }); - - area.set_draw_func((_area, cr, width, height) => { - const ctx = _area.get_style_context(); - const color = ctx.get_color(); - const r = color.red; - const g = color.green; - const b = color.blue; - - const center_x = width / 2; - const center_y = height / 2; - const radius = Math.min(width, height) / 2 - 6; - - cr.setSourceRGBA(r, g, b, 0.15); - cr.setLineWidth(6); - cr.arc(center_x, center_y, radius, 0, 2 * Math.PI); - cr.stroke(); - - if (currentValue > 0) { - cr.setSourceRGBA(r, g, b, 1.0); - cr.setLineWidth(6); - cr.setLineCap(1); // ROUND - cr.arc(center_x, center_y, radius, 1.5 * Math.PI, 1.5 * Math.PI + currentValue * 2 * Math.PI); - cr.stroke(); - } - }); +export default function CircularProgress({ + variable, + transformer, + icon, + label, + sublabel, + cssClass, +}: CircularProgressProps) { + let area!: Gtk.DrawingArea; + const areaWidget = ( + (area = self)} + /> + ); + const animation = new CircularProgressAnimation(area, variable, transformer); + onCleanup(() => animation.dispose()); - const textContainer = ( - - - + return ( + + {areaWidget} + + + + - - ); - - return ( - { - self.add_overlay(textContainer as Gtk.Widget); - }} - > - {area} ); } From 5e29adcbb3d89cd711ab146e07196e573c6b00e9 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:17:56 +0900 Subject: [PATCH 16/85] refactor: encapsulate notification image resources --- src/widget/common/NotificationCard.tsx | 217 ++++++++++++------------- 1 file changed, 103 insertions(+), 114 deletions(-) diff --git a/src/widget/common/NotificationCard.tsx b/src/widget/common/NotificationCard.tsx index 82b06f2..b490b3c 100644 --- a/src/widget/common/NotificationCard.tsx +++ b/src/widget/common/NotificationCard.tsx @@ -1,99 +1,97 @@ -import { onCleanup } from 'gnim'; import system from 'system'; +import { onCleanup } from 'ags'; import { Gtk } from 'ags/gtk4'; +import { type Timer, timeout } from 'ags/time'; import Notifd from 'gi://AstalNotifd'; -import Gdk from 'gi://Gdk'; import Pango from 'gi://Pango'; import { loadTextureFromUri } from '../../lib/image'; -import { LucideIcon } from '../../widget/common/lucide'; +import { LucideIcon } from './lucide'; -function resolveImage(img: string | null) { - if (!img) return null; - if (img.startsWith('file://')) return img; - if (img.startsWith('/')) return `file://${img}`; +export interface NotificationCardProps { + notif: Notifd.Notification; + onDismiss?: () => void; +} + +const IMAGE_RELEASE_DELAY_MS = 300; + +function resolveImage(image: string | null) { + if (!image) return null; + if (image.startsWith('file://')) return image; + if (image.startsWith('/')) return `file://${image}`; return null; } -export default function NotificationCard({ - notif, - onDismiss, -}: { - notif: Notifd.Notification; - onDismiss?: () => void; -}) { +function collectGarbage() { + try { + system.gc(); + } catch (error) { + console.error(error); + } +} + +class NotificationImageResources { + appIconPicture: Gtk.Picture | null = null; + imagePicture: Gtk.Picture | null = null; + + private releaseTimer: Timer | null = null; + private disposed = false; + private readonly resolvedHook: number; + + constructor(private readonly notification: Notifd.Notification) { + this.resolvedHook = notification.connect('resolved', () => this.scheduleRelease()); + } + + private scheduleRelease() { + this.releaseTimer?.cancel(); + this.releaseTimer = timeout(IMAGE_RELEASE_DELAY_MS, () => { + this.releaseTimer = null; + this.releaseImages(); + collectGarbage(); + }); + } + + private releaseImages() { + this.appIconPicture?.set_paintable(null); + this.imagePicture?.set_paintable(null); + this.appIconPicture = null; + this.imagePicture = null; + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.releaseTimer?.cancel(); + this.releaseTimer = null; + this.releaseImages(); + this.notification.disconnect(this.resolvedHook); + collectGarbage(); + } +} + +export default function NotificationCard({ notif, onDismiss }: NotificationCardProps) { const appIcon = notif.app_icon || notif.desktop_entry || notif.image; const appIconPath = resolveImage(appIcon); const imageToDisplay = resolveImage(notif.image); - - const timeStr = new Date(notif.time * 1000).toLocaleTimeString([], { + const time = new Date(notif.time * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); + const resources = new NotificationImageResources(notif); - let appIconPic: Gtk.Picture | null = null; - let imagePic: Gtk.Picture | null = null; - let releaseTimeout: ReturnType | null = null; - let isDestroyed = false; - - const releaseImages = () => { - if (appIconPic) { - appIconPic.set_paintable(null as unknown as Gdk.Paintable); - appIconPic = null; - } - if (imagePic) { - imagePic.set_paintable(null as unknown as Gdk.Paintable); - imagePic = null; - } - }; - - const collectGarbage = () => { - try { - system.gc(); - } catch (e) { - console.error(e); - } - }; - - const resolvedHook = notif.connect('resolved', () => { - if (releaseTimeout) clearTimeout(releaseTimeout); - releaseTimeout = setTimeout(() => { - releaseImages(); - releaseTimeout = null; - collectGarbage(); - }, 300); - }); - - const cleanup = () => { - if (isDestroyed) return; - isDestroyed = true; - if (releaseTimeout) { - clearTimeout(releaseTimeout); - releaseTimeout = null; - } - releaseImages(); - notif.disconnect(resolvedHook); - collectGarbage(); - }; - - const onDestroy = () => { - cleanup(); - }; - - onCleanup(cleanup); + onCleanup(() => resources.dispose()); return ( resources.dispose()} class={`notif-card urgency-${notif.urgency}`} orientation={Gtk.Orientation.VERTICAL} spacing={8} widthRequest={380} > - {/* ICON */} {appIconPath ? ( - { - const dummyBox = () as Gtk.Widget; - self.set_child(dummyBox); - - const pic = new Gtk.Picture({ - canFocus: false, - canShrink: true, - contentFit: Gtk.ContentFit.CONTAIN, - }); - appIconPic = pic; - try { - pic.set_paintable(loadTextureFromUri(appIconPath, 64, 64)); - } catch (e) { - console.error(e); - } - self.add_overlay(pic); - }} - /> + + + { + resources.appIconPicture = picture; + try { + picture.set_paintable(loadTextureFromUri(appIconPath, 64, 64)); + } catch (error) { + console.error(error); + } + }} + /> + ) : appIcon ? ( @@ -128,7 +124,6 @@ export default function NotificationCard({ )} - {/* SUMMARY & APP NAME & TIME */}