#!/usr/bin/env python3 """Private Sway only; production Dock/Menu, real Python bridge and input paths.""" import json import os import shutil from pathlib import Path import subprocess import sys import time REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO / 'tests/session_v2/fixtures')) from headless import Lab from ipc_proxy import IpcProxy SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway' ARTIFACTS = Path(os.environ.get('LABFY_DOCK_TEST_ARTIFACTS', str(REPO / 'docs/validation/quickshell-dock-tela-polish-v2'))) NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop', 'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop', 'org.kde.digikam.desktop', 'AppFlowy.desktop'] class DockLab(Lab): def __init__(self): super().__init__() self.config=self.directory/'dock-sway.conf' base=(REPO/'tests/session_v2/fixtures/headless.conf').read_text() effects=(REPO/'sway/.config/sway/swayfx').read_text().split('layer_effects "labfy-sway-dock"')[1] effects='layer_effects "labfy-sway-dock"'+effects self.config.write_text(base+'\noutput HEADLESS-1 bg #11111b solid_color\nset $bar_blur enable\n'+effects) def start(self, argv): if argv[0]==os.environ.get('LABFY_TEST_SWAY','sway'): argv=[*argv[:-1],str(self.config)] return super().start(argv) with DockLab() as lab: proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket) lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock') # Capture helper in private HOME, bound only to the private Wayland socket. capture_tool=Path(lab.env['HOME'])/'.local/libexec/labfy-wlthumbs' capture_tool.parent.mkdir(parents=True,exist_ok=True) shutil.copy2(Path.home()/'.local/libexec/labfy-wlthumbs',capture_tool) path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json' path.parent.mkdir(parents=True) path.write_text(json.dumps(dict(version=1, ids=NINE))) qml = lab.directory / 'fixture.qml' qml.write_text('''//@ pragma UseQApplication import QtQuick import QtTest import Quickshell import Quickshell.Io import "SOURCE/applications" as A import "SOURCE/dock" as D ShellRoot { D.Dock { id: dock; modelData: Quickshell.screens[0] } PanelWindow { id: bar; implicitWidth: 100; implicitHeight: 30; anchors.top: true; color: "transparent" } LazyLoader { id: menu; active: false; A.ApplicationsMenu { barWindow: bar } } // TEST_ONLY: one pointer move per animation frame avoids measuring IPC // process startup instead of continuous production hover. These are Qt // animation intervals, not hardware presentation timestamps. FrameAnimation { id: sweepFrames property var intervals: [] property real origin: 0 property bool geometryChanged: false property bool imageChanged: false property var baseline: [] running: false onTriggered: { if (currentFrame > 1) intervals.push(frameTime * 1000); const cycle = (elapsedTime * 0.7) % 2; input.mouseMove(dock.surfaceBody, 51 + 420 * (cycle <= 1 ? cycle : 2 - cycle), 70); const tiles=Array.from(dock.contentItemRow.children).filter(t => t.modelData); for (let i=0; i= 4.5) running=false; } } TestCase { id: input; name: "DockInput"; when: false } IpcHandler { target: "dockTest" function state(): string { return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids, error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed, separators:A.FavoritesService.separators, dividerIds:Array.from(dock.contentItemRow.children).filter(t=>t.modelData && t.modelData.kind).map(t=>t.modelData.id), entries:dock.entries, theme:A.FavoritesService.iconTheme, menu:menu.active ? menu.item.favoriteIds : null, searchFocused:menu.active && menu.item.searchFocused, navFocused:menu.active && menu.item.navigationFocused, gridFocused:menu.active && menu.item.gridFocused, screenWidth:dock.screen.width, bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX, bodyX:dock.surfaceBody.x, bodyWidth:dock.surfaceBody.width, sensorWidth:dock.edgeArea.width, bodyMaskWidth:dock.bodyMaskRegion.width, dismissMaskWidth:dock.dismissMaskRegion.width, contextMaskWidth:dock.contextMaskRegion.width, previewMaskWidth:dock.previewMaskRegion.width, previewVisible:dock.previewVisible, pendingPreviewId:dock.pendingPreviewId, previewArmRunning:dock.previewArmRunning, previewGeneration:dock.previewGeneration, previewWindowIds:dock.previewEntry ? dock.previewEntry.windows.map(w=>w.id) : [], previewCardCount:Array.from(dock.previewCardsRow.children).filter(c=>c.windowData).length, previewImageCount:Object.keys(dock.previewImages).length, hoveredTiles:Array.from(dock.contentItemRow.children) .filter(t=>t.modelData && t.children[3] && t.children[3].containsMouse) .map(t=>t.modelData.id), dockPressed:dock.pressed, dotCounts:Array.from(dock.contentItemRow.children) .filter(t=>t.modelData && !t.modelData.kind) .map(t=>({id:t.modelData.id,dots:t.dotCount})), tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0, tiles:Array.from(dock.contentItemRow.children).filter(t => t.modelData).map(t => ({ x:t.x, width:t.width, growth:t.growth, scale:t.children[1].scale, visualLeft:t.children[1].mapToItem(dock.contentItemRow.parent.parent,0,0).x, visualRight:t.children[1].mapToItem(dock.contentItemRow.parent.parent,46,0).x, iconWidth:t.children[1].width, imageStatus:t.children[1].children[0].status})), context:!!dock.contextEntry, contextAlpha:dock.contextCard.color.a, dismissVisible:dock.contextDismissArea.visible}); } function startFrames(): void { sweepFrames.intervals=[]; sweepFrames.origin=dock.width; sweepFrames.baseline=Array.from(dock.contentItemRow.children).filter(t => t.modelData) .map(t => ({x:t.x,width:t.width,status:t.children[1].children[0].status})); sweepFrames.start(); } function frames(): string { return JSON.stringify({running:sweepFrames.running, intervals:sweepFrames.intervals, geometryChanged:sweepFrames.geometryChanged,imageChanged:sweepFrames.imageChanged}); } function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); } function edgeLeft(): void { input.mouseMove(dock.edgeArea, 2, 2); } function edgeRight(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width-3, 2); } function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); } function hoverPinned(id:string): void { const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.id===id); if(!tile) throw new Error('No tile '+id); input.mouseMove(tile.children[3],tile.width/2,65); } function hoverPreview(): void { input.mouseMove(dock.previewPopupItem,25,30); } function clickPreview(index:int): void { const cards=Array.from(dock.previewCardsRow.children).filter(c=>c.windowData); if(index<0 || index>=cards.length) throw new Error('No preview card'); input.mouseClick(cards[index],cards[index].width/2,cards[index].height/2); } function sweep(x:real): void { input.mouseMove(dock.surfaceBody, x, 70); } function scrollEnd(): void { const v=dock.contentItemRow.parent.parent; v.contentX=Math.max(0,v.contentWidth-v.width); } function scrollStart(): void { dock.contentItemRow.parent.parent.contentX=0; } function leave(): void { input.mouseMove(dock.contentItem, 1, 1); } function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); } function dockToggle(id:string): void { A.FavoritesService.toggle(id); } function addSeparatorAt(position:int): void { A.FavoritesService.addSeparator(position); } function moveSeparatorTo(id:string,position:int): void { A.FavoritesService.moveSeparator(id,position); } function removeSeparator(id:string): void { A.FavoritesService.removeSeparator(id); } function openSeparatorMenu(): void { const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.kind==="separator"); if (!tile) throw new Error("missing separator tile"); input.mouseClick(tile,tile.width/2,65,Qt.RightButton); } function removeSeparatorFromMenu(): void { const action=input.findChild(dock.contextCard,"dock-remove-separator"); input.mouseClick(action,action.width/2,action.height/2); } function move(id:string, before:string): void { A.FavoritesService.move(id,before); } function closeMenu(): void { menu.active=false; } function tab(): void { input.keyClick(Qt.Key_Tab); } function clickPin(): void { const action=input.findChild(dock.contextCard,"dock-pin-action"); input.mouseClick(action,action.width/2,action.height/2); } function rightClick(): void { input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton); } function hoverContext(): void { input.mouseMove(dock.contextCard,15,15); } function clickAside(): void { const area=dock.contextDismissArea; input.mouseClick(area,5,Math.min(95,area.height-8)); } function dragFirstAfterSecond(): void { const first=dock.contentItemRow.children[0]; input.mousePress(first,27,65,Qt.LeftButton); input.mouseMove(first,125,65,100); input.mouseRelease(first,125,65,Qt.LeftButton); } function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); } function context(): void { dock.openContext(dock.entries[0],dock.surfaceBody.width/2); } function closeContext(): void { dock.closeContext(); } function suppress(value:bool): void { dock.suppressed=value; } function focus(id:string): void { A.FavoritesService.focusWindow(id); } } } '''.replace('SOURCE', SOURCE.as_uri())) lab.env['QT_QPA_PLATFORM'] = 'wayland' process = lab.start(['quickshell', '-p', str(qml)]) def call(name, *args): r = subprocess.run(['quickshell','ipc','-p',str(qml),'call','dockTest',name,*map(str,args)], env=lab.env, text=True, capture_output=True, timeout=8) if r.returncode: logs = '\n'.join(p.read_text()[-9000:] for p in lab.directory.glob('child-*.log')) raise AssertionError((name,r.stdout,r.stderr,logs)) return json.loads(r.stdout) if r.stdout.strip() else None def wait(predicate, seconds=8): end = time.monotonic()+seconds while time.monotonic()