From caaac803dd5518f7d7c42e63c85802874196822a Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 15:07:46 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(dock):=20BarShelf=20Dock=20=E2=80=94?= =?UTF-8?q?=20widgets=20and=20launchers=20on=20a=20screen=20edge,=20profil?= =?UTF-8?q?es,=20Apple=20Dock=20layouts=20(R15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optional dock (off by default) on the bottom, left, or right edge: apps, folders (colour tiles, contents menu), files, links, Shortcuts, spaces, dividers, Trash, open apps, and live BarShelf widgets. Classic (Apple Dock look, glass, magnification) or Shelf style; auto-hide. Modes: off, alongside the Apple Dock, or instead of it. Replacing hides the Apple Dock with autohide + a long delay after backing up the user's own settings; they come back on mode change, at quit, after a crash at next launch, or with `barshelf dock restore-apple-dock`. Profiles carry dock items, an optional saved Apple Dock layout (written back verbatim, previous layout backed up, Dock restarted only when it differs) and a popup page. Switch with ⌃⌥1–9, swipe or ⌘-scroll on the dock, the app menus, barshelf://dock?profile=…, or `barshelf dock use`; Focus follows through a Shortcuts automation. Dock widgets count as on screen for the scheduler, like menu bar items. Co-Authored-By: Claude Opus 5.5 (1M context) --- Localization/Localizable.xcstrings | 877 ++++++++++++++++++ README.md | 11 + .../BarShelfKit/BarShelfMain.swift | 4 + .../BarShelfCLI/BarShelfKit/DockCommand.swift | 117 +++ Sources/MenubucketApp/AppCommands.swift | 70 +- Sources/MenubucketApp/Dock/DockActions.swift | 278 ++++++ Sources/MenubucketApp/Dock/DockHotkeys.swift | 96 ++ .../Dock/DockPanelController.swift | 358 +++++++ .../MenubucketApp/Dock/DockSettingsPage.swift | 489 ++++++++++ Sources/MenubucketApp/Dock/DockStore.swift | 239 +++++ Sources/MenubucketApp/Dock/DockView.swift | 743 +++++++++++++++ Sources/MenubucketApp/Hub/HubView.swift | 2 + .../Hub/HubWindowController.swift | 7 +- .../MenubucketApp/Popover/ShelfMenus.swift | 17 + .../MenubucketApp/StatusItemController.swift | 51 + Sources/MenubucketApp/WidgetInstaller.swift | 6 + Sources/MenubucketApp/WidgetRuntime.swift | 34 +- Sources/MenubucketApp/main.swift | 5 + Sources/MenubucketCore/AppleDock.swift | 315 +++++++ Sources/MenubucketCore/Dock.swift | 449 +++++++++ Tests/BarShelfCLITests/DockCommandTests.swift | 79 ++ .../MenubucketAppTests/AppCommandsTests.swift | 2 +- Tests/MenubucketAppTests/DockStoreTests.swift | 173 ++++ .../DockConfigurationTests.swift | 215 +++++ docs/CLI.md | 2 + docs/DOCK.md | 93 ++ 26 files changed, 4723 insertions(+), 9 deletions(-) create mode 100644 Sources/BarShelfCLI/BarShelfKit/DockCommand.swift create mode 100644 Sources/MenubucketApp/Dock/DockActions.swift create mode 100644 Sources/MenubucketApp/Dock/DockHotkeys.swift create mode 100644 Sources/MenubucketApp/Dock/DockPanelController.swift create mode 100644 Sources/MenubucketApp/Dock/DockSettingsPage.swift create mode 100644 Sources/MenubucketApp/Dock/DockStore.swift create mode 100644 Sources/MenubucketApp/Dock/DockView.swift create mode 100644 Sources/MenubucketCore/AppleDock.swift create mode 100644 Sources/MenubucketCore/Dock.swift create mode 100644 Tests/BarShelfCLITests/DockCommandTests.swift create mode 100644 Tests/MenubucketAppTests/DockStoreTests.swift create mode 100644 Tests/MenubucketCoreTests/DockConfigurationTests.swift create mode 100644 docs/DOCK.md diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index e06a0c8..e08cfe7 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -58,6 +58,16 @@ } } }, + "%@ Copy": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 사본" + } + } + } + }, "%@ accent": { "localizations": { "ko": { @@ -85,6 +95,16 @@ } } }, + "%@ has no Apple Dock layout saved; switching to it leaves the Apple Dock as it is.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 저장된 Apple Dock 레이아웃이 없습니다. 이 프로필로 바꿔도 Apple Dock은 그대로입니다." + } + } + } + }, "%@ is included more than once.": { "localizations": { "ko": { @@ -226,6 +246,22 @@ } } }, + "%@, and %lld more": { + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "%1$@, and %2$lld more" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 외 %lld개" + } + } + } + }, "%lld": { "localizations": { "ko": { @@ -252,6 +288,16 @@ } } }, + "%lld more…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld개 더…" + } + } + } + }, "%lld ms": { "comment": "A duration in milliseconds", "localizations": { @@ -479,6 +525,16 @@ } } }, + "A dock for apps, folders, and widgets, with profiles to switch between.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱·폴더·위젯을 올리는 독. 프로필로 구성을 바꿀 수 있습니다." + } + } + } + }, "A graph needs the item's own place in the menu bar; sharing the BarShelf icon, it shows text only.": { "localizations": { "ko": { @@ -519,6 +575,26 @@ } } }, + "A sturdier bar with names under icons; roomy for widgets.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 아래에 이름이 붙는 단단한 막대. 위젯을 올리기 좋습니다." + } + } + } + }, + "A web page to open from the dock.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에서 열 웹 페이지입니다." + } + } + } + }, "A widget can also show its reading right in the menu bar — a battery level, CPU load, a temperature.": { "localizations": { "ko": { @@ -620,6 +696,26 @@ } } }, + "Add Link": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크 추가" + } + } + } + }, + "Add Profile…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 추가…" + } + } + } + }, "Add Widget": { "localizations": { "ko": { @@ -680,6 +776,16 @@ } } }, + "Add to Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 추가" + } + } + } + }, "Add ⌘, ⌥, ⌃, or ⇧ so the shortcut doesn't fire while you type.": { "localizations": { "ko": { @@ -690,6 +796,16 @@ } } }, + "Adds the Apple Dock's apps, folders, and files to this profile.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock의 앱·폴더·파일을 이 프로필에 추가합니다." + } + } + } + }, "Advanced": { "localizations": { "ko": { @@ -801,6 +917,16 @@ } } }, + "Alongside the Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock과 함께" + } + } + } + }, "An SF Symbol name or an emoji. Uncheck for no icon.": { "localizations": { "ko": { @@ -852,6 +978,26 @@ } } }, + "Apple Dock Layout": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock 레이아웃" + } + } + } + }, + "Apply Now": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 적용" + } + } + } + }, "Apply Preset": { "localizations": { "ko": { @@ -862,6 +1008,26 @@ } } }, + "App…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱…" + } + } + } + }, + "Arrange the Apple Dock the way you want it for this profile, then save. Switching restarts the Dock briefly; open apps and windows stay as they are. The previous layout is backed up first.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 프로필에 맞게 Apple Dock을 정리한 뒤 저장하세요. 전환할 때 Dock이 잠깐 다시 시작되지만 열린 앱과 창은 그대로입니다. 이전 레이아웃은 먼저 백업됩니다." + } + } + } + }, "Arrange your pages and widgets.": { "localizations": { "ko": { @@ -942,6 +1108,16 @@ } } }, + "Automatically hide and show the dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독을 자동으로 숨기고 보이기" + } + } + } + }, "Automation": { "localizations": { "ko": { @@ -1049,6 +1225,16 @@ } } }, + "BarShelf Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "BarShelf Dock" + } + } + } + }, "BarShelf Widgets": { "localizations": { "ko": { @@ -1252,6 +1438,16 @@ } } }, + "Bottom": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래" + } + } + } + }, "Build": { "localizations": { "ko": { @@ -1639,6 +1835,16 @@ } } }, + "Classic": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "클래식" + } + } + } + }, "Clear Filters": { "localizations": { "ko": { @@ -1888,6 +2094,16 @@ } } }, + "Copy Link": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크 복사" + } + } + } + }, "Copy a field": { "localizations": { "ko": { @@ -1908,6 +2124,16 @@ } } }, + "Copy from Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock에서 가져오기" + } + } + } + }, "Copy install command": { "localizations": { "ko": { @@ -2010,6 +2236,16 @@ } } }, + "Couldn't save dock settings: %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 설정을 저장하지 못했습니다: %@" + } + } + } + }, "Couldn't save settings: %@": { "localizations": { "ko": { @@ -2203,6 +2439,26 @@ } } }, + "Delete": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + } + } + }, + "Delete %@?": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@을(를) 삭제할까요?" + } + } + } + }, "Delete this widget from your Mac": { "localizations": { "ko": { @@ -2213,6 +2469,16 @@ } } }, + "Delete…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제…" + } + } + } + }, "Demo": { "localizations": { "ko": { @@ -2365,6 +2631,47 @@ } } }, + "Divider": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "구분선" + } + } + } + }, + "Dock": { + "comment": "App menu: the BarShelf Dock submenu\nHub page: the BarShelf Dock", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독" + } + } + } + }, + "Dock Settings…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 설정…" + } + } + } + }, + "Dock profile: %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 프로필: %@" + } + } + } + }, "Docker containers": { "localizations": { "ko": { @@ -2375,6 +2682,16 @@ } } }, + "Don't change": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "바꾸지 않음" + } + } + } + }, "Done": { "localizations": { "ko": { @@ -2522,6 +2839,16 @@ } } }, + "Drop apps, folders, or files here": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱, 폴더, 파일을 여기에 놓으세요" + } + } + } + }, "Drop here": { "localizations": { "ko": { @@ -2612,6 +2939,16 @@ } } }, + "Each profile has its own dock items, and can carry an Apple Dock layout and a popup page. Drag to reorder; the first nine get ⌃⌥1–9.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필마다 독 아이템이 따로 있고, Apple Dock 레이아웃과 팝업 페이지를 함께 담을 수 있습니다. 끌어서 순서를 바꾸세요. 처음 아홉 개에 ⌃⌥1–9가 붙습니다." + } + } + } + }, "Edit": { "comment": "The main menu's Edit menu (Undo, Cut, Copy, Paste).", "localizations": { @@ -2643,6 +2980,16 @@ } } }, + "Empty Folder": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "빈 폴더" + } + } + } + }, "Enable": { "localizations": { "ko": { @@ -3106,6 +3453,26 @@ } } }, + "Folder icon": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 아이콘" + } + } + } + }, + "Folder or File…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 또는 파일…" + } + } + } + }, "For example ⇧⌘B.": { "localizations": { "ko": { @@ -3116,6 +3483,16 @@ } } }, + "Forget": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지우기" + } + } + } + }, "Full Width": { "comment": "Widget size name", "localizations": { @@ -3442,6 +3819,16 @@ } } }, + "Hide": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "가리기" + } + } + } + }, "Hide Details": { "localizations": { "ko": { @@ -3562,6 +3949,16 @@ } } }, + "Icon size": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 크기" + } + } + } + }, "Icon, login, and sounds.": { "localizations": { "ko": { @@ -3919,6 +4316,16 @@ } } }, + "Instead of the Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock 대신" + } + } + } + }, "It keeps refreshing while the popup is closed. At most %lld widgets are shown.": { "localizations": { "ko": { @@ -3949,6 +4356,16 @@ } } }, + "Items in %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 아이템" + } + } + } + }, "Items that share the BarShelf icon appear next to it in this order. An item of its own can be moved by holding ⌘ and dragging it in the menu bar.": { "localizations": { "ko": { @@ -3959,6 +4376,16 @@ } } }, + "Its items and saved Apple Dock layout are removed. Nothing on disk is touched.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이템과 저장된 Apple Dock 레이아웃이 지워집니다. 디스크의 파일은 그대로입니다." + } + } + } + }, "Its own item": { "localizations": { "ko": { @@ -4059,6 +4486,16 @@ } } }, + "Keep in Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 유지" + } + } + } + }, "Keeps the item the same width as a reading goes from 9 to 10. A longer reading widens it once and it stays that wide.": { "localizations": { "ko": { @@ -4230,6 +4667,26 @@ } } }, + "Left": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽" + } + } + } + }, + "Like the Apple Dock: icons on glass, names on hover.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock처럼: 유리 위의 아이콘, 가리키면 이름 표시." + } + } + } + }, "Limit number of rows": { "localizations": { "ko": { @@ -4250,6 +4707,26 @@ } } }, + "Link to %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 링크" + } + } + } + }, + "Link…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크…" + } + } + } + }, "List": { "comment": "Widget display style", "localizations": { @@ -4321,6 +4798,16 @@ } } }, + "Loading…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "불러오는 중…" + } + } + } + }, "Look": { "comment": "Menu bar settings tab: appearance\nWidget inspector tab: appearance", "localizations": { @@ -4372,6 +4859,16 @@ } } }, + "Magnification": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "확대" + } + } + } + }, "Make this card full width": { "localizations": { "ko": { @@ -4787,6 +5284,16 @@ } } }, + "Name": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + } + } + }, "Name & placement": { "localizations": { "ko": { @@ -4877,6 +5384,16 @@ } } }, + "New Profile": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 프로필" + } + } + } + }, "New page name": { "localizations": { "ko": { @@ -4907,6 +5424,16 @@ } } }, + "No Shortcuts found": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축어가 없습니다" + } + } + } + }, "No data yet": { "localizations": { "ko": { @@ -4927,6 +5454,16 @@ } } }, + "No items yet. Add some below, or drop apps, folders, and files on the dock.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 아이템이 없습니다. 아래에서 추가하거나 앱·폴더·파일을 독에 끌어다 놓으세요." + } + } + } + }, "No matches": { "localizations": { "ko": { @@ -5128,6 +5665,16 @@ } } }, + "Off": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "끔" + } + } + } + }, "On open": { "comment": "Widget refresh interval: only when the popup opens", "localizations": { @@ -5249,6 +5796,16 @@ } } }, + "Open Shortcuts": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축어 열기" + } + } + } + }, "Open a field as a URL": { "localizations": { "ko": { @@ -5289,6 +5846,16 @@ } } }, + "Open in Finder": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Finder에서 열기" + } + } + } + }, "Open it from anywhere": { "localizations": { "ko": { @@ -5661,6 +6228,26 @@ } } }, + "Popup page": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "팝업 페이지" + } + } + } + }, + "Position on screen": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면상의 위치" + } + } + } + }, "Posts notifications": { "localizations": { "ko": { @@ -5741,6 +6328,46 @@ } } }, + "Profile": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필" + } + } + } + }, + "Profile name": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 이름" + } + } + } + }, + "Profiles": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필" + } + } + } + }, + "Profiles below also work with the BarShelf Dock off: they can switch the Apple Dock's apps.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래 프로필은 BarShelf Dock을 꺼도 동작합니다. Apple Dock의 앱을 바꿀 수 있습니다." + } + } + } + }, "Project Page": { "localizations": { "ko": { @@ -5781,6 +6408,16 @@ } } }, + "Quit": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료" + } + } + } + }, "Quit BarShelf": { "localizations": { "ko": { @@ -6251,6 +6888,16 @@ } } }, + "Remove from Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에서 제거" + } + } + } + }, "Remove from Menu Bar": { "localizations": { "ko": { @@ -6261,6 +6908,16 @@ } } }, + "Remove from the dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에서 제거" + } + } + } + }, "Remove header %lld": { "localizations": { "ko": { @@ -6321,6 +6978,16 @@ } } }, + "Replace with Current Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 Apple Dock으로 바꾸기" + } + } + } + }, "Requested permissions:": { "localizations": { "ko": { @@ -6511,6 +7178,16 @@ } } }, + "Right": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽" + } + } + } + }, "Right-align numbers": { "localizations": { "ko": { @@ -6583,6 +7260,16 @@ } } }, + "Run Shortcut": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축어 실행" + } + } + } + }, "Run a JSON command or use sample JSON to auto-fill fields. You can also type field paths manually.": { "localizations": { "ko": { @@ -6639,6 +7326,16 @@ } } }, + "Running": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중" + } + } + } + }, "Running…": { "localizations": { "ko": { @@ -6710,6 +7407,16 @@ } } }, + "Save Current Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 Apple Dock 저장" + } + } + } + }, "Save Script": { "localizations": { "ko": { @@ -6730,6 +7437,16 @@ } } }, + "Saved in %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 저장됨" + } + } + } + }, "Saved separately from the original file. No Hammerspoon settings are modified.": { "localizations": { "ko": { @@ -7122,6 +7839,16 @@ } } }, + "Show Trash": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통 보기" + } + } + } + }, "Show Widget": { "localizations": { "ko": { @@ -7282,6 +8009,16 @@ } } }, + "Show open apps that aren't in the dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 없는 열린 앱 보기" + } + } + } + }, "Show or hide the popup from any app.": { "localizations": { "ko": { @@ -7556,6 +8293,16 @@ } } }, + "Starts empty. Add apps, folders, and widgets below.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "빈 상태로 시작합니다. 아래에서 앱·폴더·위젯을 추가하세요." + } + } + } + }, "Static text": { "localizations": { "ko": { @@ -7674,6 +8421,36 @@ } } }, + "Switch": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전환" + } + } + } + }, + "Switch profiles with ⌃⌥1–9": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌃⌥1–9로 프로필 전환" + } + } + } + }, + "Switch the Apple Dock's apps with the profile": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필에 따라 Apple Dock의 앱 바꾸기" + } + } + } + }, "Switches": { "comment": "Menu bar icon choice", "localizations": { @@ -7705,6 +8482,26 @@ } } }, + "Switching": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전환" + } + } + } + }, + "Symbol": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "기호" + } + } + } + }, "Table": { "comment": "Widget display style", "localizations": { @@ -7845,6 +8642,26 @@ } } }, + "The Apple Dock is hidden while BarShelf runs and comes back when you quit BarShelf or turn this off. If it ever stays hidden, run barshelf dock restore-apple-dock in Terminal.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "BarShelf가 실행되는 동안 Apple Dock은 숨겨지고, BarShelf를 종료하거나 이 옵션을 끄면 돌아옵니다. 계속 숨겨져 있다면 터미널에서 barshelf dock restore-apple-dock을 실행하세요." + } + } + } + }, + "The Apple Dock is on the same edge. The BarShelf Dock sits just above it; another position, or auto-hide, keeps them apart.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock이 같은 가장자리에 있습니다. BarShelf Dock은 그 바로 위에 놓입니다. 위치를 바꾸거나 자동 숨기기를 켜면 겹치지 않습니다." + } + } + } + }, "The address this widget reads wasn’t found (HTTP 404).": { "localizations": { "ko": { @@ -8342,6 +9159,16 @@ } } }, + "To follow a Focus: in Shortcuts, add a personal automation for when the Focus turns on, with the Open URLs action and this link. From Terminal: barshelf dock use \"%@\".": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "집중 모드에 맞추려면: 단축어 앱에서 집중 모드가 켜질 때 실행되는 개인용 자동화를 만들고 ‘URL 열기’ 동작에 이 링크를 넣으세요. 터미널에서는: barshelf dock use \"%@\"." + } + } + } + }, "Trailing value": { "localizations": { "ko": { @@ -8352,6 +9179,16 @@ } } }, + "Trash": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통" + } + } + } + }, "Tray": { "comment": "Menu bar icon choice", "localizations": { @@ -9047,6 +9884,16 @@ } } }, + "Widget not installed": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치되지 않은 위젯" + } + } + } + }, "Widget search": { "localizations": { "ko": { @@ -9077,6 +9924,16 @@ } } }, + "Widget size": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "위젯 크기" + } + } + } + }, "Widgets": { "localizations": { "ko": { @@ -9179,6 +10036,16 @@ } } }, + "You can also swipe sideways with two fingers on the dock, or scroll over it holding ⌘, or pick a profile from the BarShelf menu.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 위에서 두 손가락으로 옆으로 쓸거나, ⌘를 누른 채 스크롤하거나, BarShelf 메뉴에서 프로필을 고를 수도 있습니다." + } + } + } + }, "You won't be reminded about this version. Newer ones still show up.": { "localizations": { "ko": { @@ -9366,6 +10233,16 @@ } } }, + "https://example.com": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://example.com" + } + } + } + }, "is": { "localizations": { "ko": { diff --git a/README.md b/README.md index a0b4717..d44231b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ new SDK to learn. - **🪟 One icon, many widgets** — bucket pages, trackpad swipe, pinned row, ⌘F search. - **📊 Live in the menu bar** — opt a widget in and its value updates right on the bar (`✦ 42% · 61% · 58°`), sharing the BarShelf icon or on its own. +- **🧭 A dock, if you want one** — put apps, folders, links, Shortcuts, and live + widgets on a screen-edge dock, alongside the Apple Dock or instead of it, and + switch profiles (⌃⌥1–9, swipe, Focus) — the Apple Dock's apps can follow. - **⚡ CLI is the API** — `aas usage --json`, `otpeek`, `gh`, `kubectl`… pipe them straight in. - **🎨 Native, not web** — SwiftUI rendering, dark mode, SF Symbols, vibrancy. No Electron. - **🧩 Three ways to build** — declarative workflows, a Shortcuts-style visual builder, or full scripts. @@ -114,6 +117,14 @@ shortcuts, and window cycling, then customize the generated JavaScript. Requires Accessibility access; no additional runtime is needed. See [setup, import scope, and scripting API](docs/AUTOMATION.md). +## BarShelf Dock + +Optional and off by default: **BarShelf ▸ Dock** adds a dock on the bottom, +left, or right edge with apps, folders, files, links, Shortcuts, and any +BarShelf widget. Run it alongside the Apple Dock or instead of it, keep several +profiles, and let each one rearrange the Apple Dock too. +See [the Dock guide](docs/DOCK.md). + ## Gallery widgets Native widgets ship in the gallery — most are declarative **workflows** diff --git a/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift b/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift index 3c557bd..cfbb46b 100644 --- a/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift +++ b/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift @@ -26,6 +26,8 @@ public enum BarShelfMain { barshelf pack [-o .mbw] Pack a widget directory into a .mbw archive (includes manifest.sha256). barshelf list List installed widgets. + barshelf dock list|use |next|previous|restore-apple-dock + Dock profiles and the Apple Dock. barshelf agent-spec Print the widget-authoring spec (docs/AGENTS.md) for LLM agents. barshelf upgrade [--check] Update the barshelf CLI and @@ -61,6 +63,8 @@ public enum BarShelfMain { return runPack(arguments: rest) case "list": return runList(arguments: rest) + case "dock": + return DockCommand.run(arguments: rest) case "agent-spec": return runAgentSpec(arguments: rest) // "update" is what people type first; accept it rather than printing diff --git a/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift b/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift new file mode 100644 index 0000000..c136517 --- /dev/null +++ b/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift @@ -0,0 +1,117 @@ +import Foundation +import MenubucketCore + +/// `barshelf dock …` — the BarShelf Dock's profiles from Terminal (R15). +/// +/// `list` reads `dock.json`; `use` asks the running app through its URL +/// scheme (the app owns the switch); `restore-apple-dock` works with the app +/// not running at all, which is the point of it. +public enum DockCommand { + public static let usage = """ + usage: + barshelf dock list List dock profiles (* = active). + barshelf dock use Switch profile by name, id, or number. + barshelf dock next | previous Switch to the next or previous profile. + barshelf dock restore-apple-dock Bring back the Apple Dock if BarShelf + left it hidden. + """ + + public static var configurationURL: URL { + HeadlessInstaller.defaultWidgetsDirectory + .deletingLastPathComponent() + .appendingPathComponent("dock.json") + } + + public static func run( + arguments: [String], + configurationURL: URL = DockCommand.configurationURL, + openURL: (URL) -> Bool = DockCommand.openInApp, + appleDock: () -> AppleDock = { AppleDock() } + ) -> Int32 { + guard let subcommand = arguments.first else { + BarShelfMain.printError(usage) + return 1 + } + let rest = Array(arguments.dropFirst()) + switch subcommand { + case "list", "ls": + let config = DockConfiguration.load(from: configurationURL) + print(list(config)) + return 0 + case "use", "switch": + let query = rest.joined(separator: " ").trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { + BarShelfMain.printError("usage: barshelf dock use ") + return 1 + } + let config = DockConfiguration.load(from: configurationURL) + guard let profile = config.profile(matching: query) else { + BarShelfMain.printError("barshelf: no dock profile matches \"\(query)\"") + BarShelfMain.printError(list(config)) + return 1 + } + return send(query: "profile=\(encode(profile.id))", openURL: openURL) + case "next": + return send(query: "next", openURL: openURL) + case "previous", "prev": + return send(query: "previous", openURL: openURL) + case "restore-apple-dock": + let dock = appleDock() + var config = DockConfiguration.load(from: configurationURL) + if let backup = config.appleDockBackup { + dock.restore(backup) + config.appleDockBackup = nil + // "Instead of the Apple Dock" would hide it again at launch. + if config.mode == .replace { config.mode = .alongside } + try? config.save(to: configurationURL) + print("restored the Apple Dock's own settings") + } else if dock.isHidden { + dock.unhideWithoutBackup() + print("the Apple Dock shows again") + } else { + print("the Apple Dock is not hidden") + } + return 0 + default: + BarShelfMain.printError("barshelf: unknown dock command \"\(subcommand)\"") + BarShelfMain.printError(usage) + return 1 + } + } + + static func list(_ config: DockConfiguration) -> String { + config.profiles.enumerated().map { index, profile in + let marker = profile.id == config.activeProfileID ? "*" : " " + var line = "\(marker) \(index + 1). \(profile.name) [\(profile.id)] \(profile.items.count) item(s)" + if profile.appleDock != nil { line += ", Apple Dock layout" } + return line + }.joined(separator: "\n") + } + + private static func encode(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))) ?? value + } + + private static func send(query: String, openURL: (URL) -> Bool) -> Int32 { + guard let url = URL(string: "barshelf://dock?\(query)") else { return 1 } + guard openURL(url) else { + BarShelfMain.printError("barshelf: couldn't reach BarShelf (is it installed?)") + return 1 + } + return 0 + } + + /// `open -g` hands the URL to BarShelf without bringing anything forward. + public static func openInApp(_ url: URL) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-g", url.absoluteString] + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } +} diff --git a/Sources/MenubucketApp/AppCommands.swift b/Sources/MenubucketApp/AppCommands.swift index 6ff07b0..b496266 100644 --- a/Sources/MenubucketApp/AppCommands.swift +++ b/Sources/MenubucketApp/AppCommands.swift @@ -4,13 +4,14 @@ import AppKit /// share. Both menus are built from `AppMenu.sections`, so the two cannot /// drift apart: a command added here appears in both. enum AppMenuCommand: Hashable { - case editShelf, addWidget, menuBar, openBarShelf, settings, checkForUpdates, quit + case editShelf, addWidget, menuBar, dock, openBarShelf, settings, checkForUpdates, quit var title: String { switch self { case .editShelf: return String(localized: "Edit Shelf") case .addWidget: return String(localized: "Add Widget…") case .menuBar: return String(localized: "Menu Bar") + case .dock: return String(localized: "Dock", comment: "App menu: the BarShelf Dock submenu") case .openBarShelf: return String(localized: "Open BarShelf…") case .settings: return String(localized: "Settings…") case .checkForUpdates: @@ -31,7 +32,7 @@ enum AppMenuCommand: Hashable { case .editShelf: return "e" case .settings: return "," case .quit: return "q" - case .addWidget, .menuBar, .openBarShelf, .checkForUpdates: return "" + case .addWidget, .menuBar, .dock, .openBarShelf, .checkForUpdates: return "" } } @@ -40,6 +41,7 @@ enum AppMenuCommand: Hashable { case .editShelf: return "pencil" case .addWidget: return "plus" case .menuBar: return "menubar.rectangle" + case .dock: return "dock.rectangle" case .openBarShelf: return "macwindow" case .settings: return "gearshape" case .checkForUpdates: return "arrow.down.circle" @@ -51,7 +53,7 @@ enum AppMenuCommand: Hashable { enum AppMenu { /// Groups, in order; a separator goes between groups. static let sections: [[AppMenuCommand]] = [ - [.editShelf, .addWidget, .menuBar], + [.editShelf, .addWidget, .menuBar, .dock], [.openBarShelf, .settings, .checkForUpdates], [.quit], ] @@ -99,6 +101,33 @@ enum AppMenu { let isOn = runtime.menuBarWidgetIDs.contains(widgetID) runtime.updateMenuBarPlacement(for: widgetID) { $0.enabled = !isOn } } + + /// One row of "Dock ▸": a profile, checked while active (R15). + struct DockProfileRow: Identifiable, Equatable { + let id: String + let title: String + let symbol: String + let isActive: Bool + } + + /// "Dock ▸" lists the profiles once there is more than one to pick from; + /// the submenu always offers the dock's settings, which is where the + /// feature is found in the first place. + static func dockProfiles(store: DockStore) -> [DockProfileRow] { + let config = store.configuration + guard config.profiles.count > 1 else { return [] } + return config.profiles.enumerated().map { index, profile in + let hotkey = config.profileHotkeysEnabled ? DockHotkeys.label(forPosition: index + 1) : nil + return DockProfileRow( + id: profile.id, + title: hotkey.map { "\(profile.name) \($0)" } ?? profile.name, + symbol: profile.symbol, + isActive: profile.id == config.activeProfileID + ) + } + } + + static var dockSettingsTitle: String { String(localized: "Dock Settings…") } } /// The app's commands as real main-menu items, so their shortcuts work in @@ -183,6 +212,10 @@ extension StatusItemController: NSMenuItemValidation { if let item = makeMenuBarSubmenuItem() { menu.addItem(item) } continue } + if command == .dock { + menu.addItem(makeDockSubmenuItem()) + continue + } let item = NSMenuItem( title: command.title, action: #selector(performAppMenuItem(_:)), @@ -227,6 +260,37 @@ extension StatusItemController: NSMenuItemValidation { perform(command, fromPopup: false) } + private func makeDockSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: AppMenuCommand.dock.title, action: nil, keyEquivalent: "") + item.image = NSImage(systemSymbolName: AppMenuCommand.dock.symbol, accessibilityDescription: nil) + let submenu = NSMenu() + submenu.autoenablesItems = false + let rows = AppMenu.dockProfiles(store: DockStore.shared) + for row in rows { + let entry = NSMenuItem(title: row.title, action: #selector(activateDockProfile(_:)), keyEquivalent: "") + entry.target = self + entry.representedObject = row.id + entry.state = row.isActive ? .on : .off + entry.image = NSImage(systemSymbolName: row.symbol, accessibilityDescription: nil) + submenu.addItem(entry) + } + if !rows.isEmpty { submenu.addItem(.separator()) } + let settings = NSMenuItem(title: AppMenu.dockSettingsTitle, action: #selector(openDockSettings(_:)), keyEquivalent: "") + settings.target = self + submenu.addItem(settings) + item.submenu = submenu + return item + } + + @objc private func activateDockProfile(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + DockStore.shared.activate(profileID: id) + } + + @objc func openDockSettings(_ sender: Any?) { + perform(.dock, fromPopup: false) + } + @objc private func toggleMenuBarWidget(_ sender: NSMenuItem) { guard let widgetID = sender.representedObject as? String else { return } AppMenu.toggleMenuBar(widgetID: widgetID, runtime: shelfRuntime) diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift new file mode 100644 index 0000000..9c8db48 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -0,0 +1,278 @@ +import AppKit +import Combine +import MenubucketCore +import UniformTypeIdentifiers + +/// Apps with a Dock presence that are running now, in launch order. Feeds the +/// running dots and the section of apps that are open but not in the profile. +final class RunningApps: ObservableObject { + struct App: Identifiable, Equatable { + let path: String + let bundleID: String? + let processID: pid_t + var id: String { path } + } + + @Published private(set) var apps: [App] = [] + @Published private(set) var frontmostPath: String? + + private var observers: [NSObjectProtocol] = [] + + init() { + let center = NSWorkspace.shared.notificationCenter + for name in [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + NSWorkspace.didActivateApplicationNotification, + NSWorkspace.didHideApplicationNotification, + NSWorkspace.didUnhideApplicationNotification, + ] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.reload() + }) + } + reload() + } + + deinit { + observers.forEach(NSWorkspace.shared.notificationCenter.removeObserver) + } + + func reload() { + let running = NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy == .regular && !$0.isTerminated } + .sorted { ($0.launchDate ?? .distantPast) < ($1.launchDate ?? .distantPast) } + .compactMap { app -> App? in + guard let url = app.bundleURL else { return nil } + return App(path: Self.key(url.path), bundleID: app.bundleIdentifier, processID: app.processIdentifier) + } + if running != apps { apps = running } + let front = NSWorkspace.shared.frontmostApplication?.bundleURL.map { Self.key($0.path) } + if front != frontmostPath { frontmostPath = front } + } + + func isRunning(path: String) -> Bool { + let key = Self.key(path) + return apps.contains { $0.path == key } + } + + func runningApplication(path: String) -> NSRunningApplication? { + let key = Self.key(path) + guard let app = apps.first(where: { $0.path == key }) else { return nil } + return NSRunningApplication(processIdentifier: app.processID) + } + + /// Paths compare without a trailing slash or `..` noise. + static func key(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } +} + +/// What clicking, dropping on, and right-clicking a dock tile does. +enum DockActions { + static var trashURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash", isDirectory: true) + } + + // MARK: Open + + static func open(_ item: DockItem) { + switch item.kind { + case .app(let path): + openApp(path: path) + case .folder(let path, _, _), .file(let path): + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + case .link(let string, _): + if let url = URL(string: string) { NSWorkspace.shared.open(url) } + case .shortcut(let name): + runShortcut(named: name) + case .widget(let id): + if let url = URL(string: "barshelf://show?widget=\(id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? id)") { + NSWorkspace.shared.open(url) + } + case .spacer, .separator: + break + } + } + + /// Launches the app, or brings it forward with a reopen event so an app + /// with no windows opens one — what clicking the Apple Dock does. + static func openApp(path: String) { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: path), configuration: configuration) + } + + static func open(_ urls: [URL], withAppAt path: String) { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.open(urls, withApplicationAt: URL(fileURLWithPath: path), configuration: configuration) + } + + static func revealInFinder(path: String) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + + static func openTrash() { + NSWorkspace.shared.open(trashURL) + } + + static func moveToTrash(_ urls: [URL]) { + for url in urls { + try? FileManager.default.trashItem(at: url, resultingItemURL: nil) + } + } + + // MARK: Shortcuts + + /// Runs a Shortcut through the `shortcuts` tool, off the main thread. + static func runShortcut(named name: String) { + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") + process.arguments = ["run", name] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() + process.waitUntilExit() + } + } + + /// The user's Shortcuts by name, for the "Add Shortcut" picker. + static func listShortcuts(completion: @escaping ([String]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") + process.arguments = ["list"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + var names: [String] = [] + if (try? process.run()) != nil { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + names = String(decoding: data, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + DispatchQueue.main.async { completion(names) } + } + } + + // MARK: Icons + + static func icon(for item: DockItem) -> NSImage? { + switch item.kind { + case .app(let path), .file(let path): + return NSWorkspace.shared.icon(forFile: path) + case .folder(let path, _, _): + return NSWorkspace.shared.icon(forFile: path) + case .shortcut: + return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.shortcuts") + .map { NSWorkspace.shared.icon(forFile: $0.path) } + default: + return nil + } + } + + static func displayName(for item: DockItem) -> String { + switch item.kind { + case .app(let path), .file(let path): + return FileManager.default.displayName(atPath: path) + .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]) + default: + return item.fallbackTitle + } + } + + // MARK: Folder menu (a "list" stack) + + /// The folder's contents as a menu, subfolders opening as submenus — + /// the Apple Dock's list view of a stack. + static func folderMenu(path: String) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + let url = URL(fileURLWithPath: path, isDirectory: true) + let open = NSMenuItem( + title: String(localized: "Open in Finder"), + action: #selector(FolderMenuTarget.openItem(_:)), keyEquivalent: "" + ) + open.target = FolderMenuTarget.shared + open.representedObject = url + menu.addItem(open) + menu.addItem(.separator()) + FolderMenuTarget.fill(menu, with: url) + return menu + } +} + +/// Target and lazy filler for folder menus; subfolders fill when opened. +final class FolderMenuTarget: NSObject, NSMenuDelegate { + static let shared = FolderMenuTarget() + /// Enough to find something in Downloads without a menu taller than the + /// screen; "Open in Finder" covers the rest. + static let limit = 80 + + static func fill(_ menu: NSMenu, with folder: URL) { + let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey, .addedToDirectoryDateKey] + let entries = ((try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] + )) ?? []) + .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + if entries.isEmpty { + let empty = NSMenuItem(title: String(localized: "Empty Folder"), action: nil, keyEquivalent: "") + empty.isEnabled = false + menu.addItem(empty) + return + } + for entry in entries.prefix(limit) { + let item = NSMenuItem( + title: FileManager.default.displayName(atPath: entry.path), + action: #selector(openItem(_:)), keyEquivalent: "" + ) + item.target = shared + item.representedObject = entry + let icon = NSWorkspace.shared.icon(forFile: entry.path) + icon.size = NSSize(width: 16, height: 16) + item.image = icon + let values = try? entry.resourceValues(forKeys: Set(keys)) + if values?.isDirectory == true, values?.isPackage != true { + let submenu = NSMenu() + submenu.autoenablesItems = false + submenu.delegate = shared + submenu.title = entry.path + item.submenu = submenu + } + menu.addItem(item) + } + if entries.count > limit { + let more = NSMenuItem( + title: String(localized: "\(entries.count - limit) more…"), + action: #selector(openItem(_:)), keyEquivalent: "" + ) + more.target = shared + more.representedObject = folder + menu.addItem(more) + } + } + + func menuNeedsUpdate(_ menu: NSMenu) { + guard menu.items.isEmpty else { return } + let folder = URL(fileURLWithPath: menu.title, isDirectory: true) + let open = NSMenuItem( + title: String(localized: "Open in Finder"), action: #selector(openItem(_:)), keyEquivalent: "" + ) + open.target = self + open.representedObject = folder + menu.addItem(open) + menu.addItem(.separator()) + Self.fill(menu, with: folder) + } + + @objc func openItem(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/Sources/MenubucketApp/Dock/DockHotkeys.swift b/Sources/MenubucketApp/Dock/DockHotkeys.swift new file mode 100644 index 0000000..4104a46 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockHotkeys.swift @@ -0,0 +1,96 @@ +import Carbon.HIToolbox +import Combine +import Foundation +import MenubucketCore + +/// ⌃⌥1…9: switch to the first nine dock profiles (R15). Carbon hot keys, like +/// the popup shortcut, so no Accessibility permission is needed. Its own +/// signature keeps its presses apart from the popup shortcut's handler. +final class DockHotkeys { + static let signature: OSType = 0x4253_444B // 'BSDK' + static let modifiers = UInt32(controlKey | optionKey) + static let digitKeyCodes: [UInt32] = [ + UInt32(kVK_ANSI_1), UInt32(kVK_ANSI_2), UInt32(kVK_ANSI_3), + UInt32(kVK_ANSI_4), UInt32(kVK_ANSI_5), UInt32(kVK_ANSI_6), + UInt32(kVK_ANSI_7), UInt32(kVK_ANSI_8), UInt32(kVK_ANSI_9), + ] + + private let store: DockStore + private var refs: [EventHotKeyRef] = [] + private var handler: EventHandlerRef? + private var registeredCount = 0 + private var cancellable: AnyCancellable? + + init(store: DockStore) { + self.store = store + cancellable = store.$configuration + .map { $0.profileHotkeysEnabled ? min($0.profiles.count, DockConfiguration.hotkeyProfileLimit) : 0 } + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] count in self?.register(count: count) } + } + + deinit { + unregisterAll() + if let handler { RemoveEventHandler(handler) } + } + + /// Shown in settings beside each profile: "⌃⌥1". + static func label(forPosition position: Int) -> String? { + guard (1...DockConfiguration.hotkeyProfileLimit).contains(position) else { return nil } + return "⌃⌥\(position)" + } + + private func register(count: Int) { + guard count != registeredCount else { return } + unregisterAll() + guard count > 0 else { return } + installHandlerIfNeeded() + for (index, keyCode) in Self.digitKeyCodes.prefix(count).enumerated() { + var ref: EventHotKeyRef? + let id = EventHotKeyID(signature: Self.signature, id: UInt32(index + 1)) + // A combination another app already owns just stays unregistered. + if RegisterEventHotKey(keyCode, Self.modifiers, id, GetApplicationEventTarget(), 0, &ref) == noErr, + let ref { + refs.append(ref) + } + } + registeredCount = count + } + + private func unregisterAll() { + refs.forEach { UnregisterEventHotKey($0) } + refs.removeAll() + registeredCount = 0 + } + + private func installHandlerIfNeeded() { + guard handler == nil else { return } + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + InstallEventHandler( + GetApplicationEventTarget(), + { _, event, userData -> OSStatus in + guard let userData, let event else { return OSStatus(eventNotHandledErr) } + var id = EventHotKeyID() + guard GetEventParameter( + event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), + nil, MemoryLayout.size, nil, &id + ) == noErr, id.signature == DockHotkeys.signature else { + return OSStatus(eventNotHandledErr) + } + let hotkeys = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let position = Int(id.id) + DispatchQueue.main.async { hotkeys.pressed(position: position) } + return noErr + }, + 1, &eventType, selfPtr, &handler + ) + } + + private func pressed(position: Int) { + let profiles = store.configuration.profiles + guard profiles.indices.contains(position - 1) else { return } + store.activate(profileID: profiles[position - 1].id) + } +} diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift new file mode 100644 index 0000000..4912782 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -0,0 +1,358 @@ +import AppKit +import Combine +import MenubucketCore +import SwiftUI + +/// A borderless panel at the Dock's window level. Never activates BarShelf, +/// so clicking an icon leaves the frontmost app frontmost until the click +/// itself switches apps. +final class DockPanel: NSPanel { + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 60), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + isOpaque = false + backgroundColor = .clear + hasShadow = false + level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.dockWindow))) + // Every Space, never in Mission Control's window list or ⌘` cycling; + // full-screen apps hide it, as they hide the Apple Dock. + collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + isMovable = false + hidesOnDeactivate = false + becomesKeyOnlyIfNeeded = true + isReleasedWhenClosed = false + animationBehavior = .none + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +/// First clicks act right away (the panel is never key when they arrive), and +/// a two-finger swipe or ⌘-scroll over the dock switches profiles. +final class DockHostingView: NSHostingView { + var onSwipe: ((Int) -> Void)? + /// The content's ideal size changed (items, sizes, running apps). + var onIdealSizeChange: (() -> Void)? + private var accumulated: CGFloat = 0 + private var firedThisGesture = false + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onIdealSizeChange?() + } + + override func scrollWheel(with event: NSEvent) { + // Momentum after the fingers lift would switch a second time. + guard event.momentumPhase.isEmpty else { return } + if event.modifierFlags.contains(.command) { + // A mouse wheel notch, or a decent trackpad flick: one profile. + let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY / 20 : event.scrollingDeltaY + if abs(delta) >= 1 { onSwipe?(delta > 0 ? -1 : 1) } + return + } + guard event.hasPreciseScrollingDeltas, abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) else { + super.scrollWheel(with: event) + return + } + if event.phase == .began { + accumulated = 0 + firedThisGesture = false + } + accumulated += event.scrollingDeltaX + if !firedThisGesture, abs(accumulated) > 60 { + firedThisGesture = true + // Content follows the fingers: swiping left brings the next one. + onSwipe?(accumulated < 0 ? 1 : -1) + } + if event.phase == .ended || event.phase == .cancelled { + accumulated = 0 + firedThisGesture = false + } + } +} + +/// Shows the BarShelf Dock on its screen edge, hides it when auto-hide says +/// so, and tells the runtime which dock widgets are on screen (R15). +final class DockPanelController { + private let store: DockStore + private let runtime: WidgetRuntime + private let running = RunningApps() + private let panel = DockPanel() + private var hostingView: DockHostingView! + private var cancellables: Set = [] + private var mouseMonitors: [Any] = [] + private var restingSize: CGSize = .zero + private var isHovering = false + private var measurePending = false + /// Whether the dock is out (not slid away by auto-hide). + private var isRevealed = true + private var hideWorkItem: DispatchWorkItem? + + /// The edge band, in points, that brings an auto-hidden dock back. + static let revealBand: CGFloat = 2 + /// Gap between the dock and the screen edge. + static let edgeGap: CGFloat = 4 + + var onOpenSettings: (() -> Void)? + + init(store: DockStore, runtime: WidgetRuntime) { + self.store = store + self.runtime = runtime + let view = DockView( + store: store, runtime: runtime, running: running, + onHoverChange: { [weak self] hovering in self?.hoverChanged(hovering) }, + onOpenSettings: { [weak self] in self?.onOpenSettings?() } + ) + hostingView = DockHostingView(rootView: view) + // The ideal size is read, not obeyed: the hosting view sits in a + // plain container that the panel's frame drives, so the bar's size + // can be measured without the window snapping to it. + hostingView.sizingOptions = [.intrinsicContentSize] + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.autoresizingMask = [.width, .height] + hostingView.onSwipe = { [weak store] offset in store?.activate(offset: offset) } + hostingView.onIdealSizeChange = { [weak self] in self?.setNeedsMeasure() } + let container = NSView(frame: panel.contentLayoutRect) + hostingView.frame = container.bounds + container.addSubview(hostingView) + panel.contentView = container + + store.$configuration + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.configurationChanged() } + .store(in: &cancellables) + runtime.$widgets + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.reportVisibleWidgets() + self?.setNeedsMeasure() + } + .store(in: &cancellables) + running.$apps + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.setNeedsMeasure() } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.placePanel(animated: false) } + .store(in: &cancellables) + configurationChanged() + } + + deinit { + removeMouseMonitors() + } + + private var config: DockConfiguration { store.configuration } + + // MARK: Showing + + private func configurationChanged() { + guard config.mode.showsDock else { + panel.orderOut(nil) + removeMouseMonitors() + runtime.setDockWidgetIDs([]) + return + } + installMouseMonitorsIfNeeded() + if !config.autoHide { isRevealed = true } + setNeedsMeasure() + placePanel(animated: false) + if isRevealed { panel.orderFrontRegardless() } + reportVisibleWidgets() + } + + private func hoverChanged(_ hovering: Bool) { + isHovering = hovering + if !hovering { setNeedsMeasure() } + } + + /// Measures the bar once SwiftUI has caught up with the change that asked + /// for it (`@Published` fires before the new value is stored). + private func setNeedsMeasure() { + guard !measurePending else { return } + measurePending = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.measurePending = false + self.measure() + } + } + + private func measure() { + guard config.mode.showsDock, !isHovering else { return } + let fitting = hostingView.intrinsicContentSize + // The edge gap is part of the SwiftUI padding; the bar is the rest. + let size = config.edge.isVertical + ? CGSize(width: fitting.width - Self.edgeGap, height: fitting.height) + : CGSize(width: fitting.width, height: fitting.height - Self.edgeGap) + guard size.width > 0, size.height > 0, size != restingSize else { return } + restingSize = size + placePanel(animated: false) + } + + /// The headroom around the bar that magnified icons and their labels + /// draw into. Transparent, so clicks there reach the windows below. + private var headroom: (along: CGFloat, across: CGFloat) { + let tile = CGFloat(config.tileSize) + let magnifies = config.style == .classic && config.magnification + let grow = magnifies ? tile * DockView.maxMagnification : 0 + // Labels sit past the grown icon; the profile banner past the bar. + return (along: magnifies ? tile * 2.5 : tile, across: grow + 48) + } + + private var screen: NSScreen? { NSScreen.screens.first ?? NSScreen.main } + + /// The panel frame for the current size and edge, out on screen or slid + /// past the edge (the current state unless `revealed` says otherwise). + private func targetFrame(revealed: Bool? = nil) -> NSRect? { + let isRevealed = revealed ?? self.isRevealed + guard let screen, restingSize != .zero else { return nil } + let visible = screen.visibleFrame + let full = screen.frame + let room = headroom + switch config.edge { + case .bottom: + let width = min(restingSize.width + room.along, full.width) + let height = restingSize.height + room.across + Self.edgeGap + // Above the Apple Dock when it shares the edge and stays up. + var y = visible.minY + if !isRevealed { y = full.minY - height } + return NSRect(x: visible.midX - width / 2, y: y, width: width, height: height) + case .left, .right: + let width = restingSize.width + room.across + Self.edgeGap + let height = min(restingSize.height + room.along, visible.height) + let y = visible.midY - height / 2 + if config.edge == .left { + let x = isRevealed ? visible.minX : full.minX - width + return NSRect(x: x, y: y, width: width, height: height) + } + let x = isRevealed ? visible.maxX - width : full.maxX + return NSRect(x: x, y: y, width: width, height: height) + } + } + + private func placePanel(animated: Bool, completion: (() -> Void)? = nil) { + guard let frame = targetFrame() else { return } + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame(frame, display: true) + } completionHandler: { + completion?() + } + } else { + panel.setFrame(frame, display: true) + completion?() + } + } + + // MARK: Auto-hide + + private func installMouseMonitorsIfNeeded() { + guard mouseMonitors.isEmpty else { return } + let handler: (NSEvent) -> Void = { [weak self] _ in self?.mouseMoved() } + if let global = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: handler) { + mouseMonitors.append(global) + } + if let local = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: { event in + handler(event) + return event + }) { + mouseMonitors.append(local) + } + } + + private func removeMouseMonitors() { + mouseMonitors.forEach(NSEvent.removeMonitor) + mouseMonitors.removeAll() + } + + private func mouseMoved() { + guard config.mode.showsDock, config.autoHide, let screen else { return } + let point = NSEvent.mouseLocation + let full = screen.frame + let atEdge: Bool = switch config.edge { + case .bottom: point.y <= full.minY + Self.revealBand && point.x >= full.minX && point.x <= full.maxX + case .left: point.x <= full.minX + Self.revealBand && point.y >= full.minY && point.y <= full.maxY + case .right: point.x >= full.maxX - Self.revealBand - 1 && point.y >= full.minY && point.y <= full.maxY + } + if !isRevealed { + if atEdge { setRevealed(true) } + return + } + let inside = barFrame().insetBy(dx: -12, dy: -12).contains(point) || atEdge + if inside { + hideWorkItem?.cancel() + hideWorkItem = nil + } else if hideWorkItem == nil { + let work = DispatchWorkItem { [weak self] in + self?.hideWorkItem = nil + self?.setRevealed(false) + } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + } + + /// The bar's own frame on screen, without the headroom. + private func barFrame() -> NSRect { + let frame = panel.frame + switch config.edge { + case .bottom: + return NSRect( + x: frame.midX - restingSize.width / 2, y: frame.minY, + width: restingSize.width, height: restingSize.height + Self.edgeGap + ) + case .left: + return NSRect( + x: frame.minX, y: frame.midY - restingSize.height / 2, + width: restingSize.width + Self.edgeGap, height: restingSize.height + ) + case .right: + return NSRect( + x: frame.maxX - restingSize.width - Self.edgeGap, y: frame.midY - restingSize.height / 2, + width: restingSize.width + Self.edgeGap, height: restingSize.height + ) + } + } + + private func setRevealed(_ revealed: Bool) { + guard revealed != isRevealed else { return } + // A menu or a drag in flight keeps the dock out. + if !revealed, NSEvent.pressedMouseButtons != 0 { return } + isRevealed = revealed + if revealed { + // Start just past the edge, then slide in. + if let hidden = targetFrame(revealed: false) { panel.setFrame(hidden, display: false) } + panel.orderFrontRegardless() + placePanel(animated: true) + } else { + // Slide out, then leave the screen for real: past the edge could + // be another display. + placePanel(animated: true) { [weak self] in + guard let self, !self.isRevealed else { return } + self.panel.orderOut(nil) + } + } + reportVisibleWidgets() + } + + // MARK: Widgets on screen + + private func reportVisibleWidgets() { + guard config.mode.showsDock, isRevealed else { + runtime.setDockWidgetIDs([]) + return + } + runtime.setDockWidgetIDs(Set(config.activeWidgetIDs)) + } +} diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift new file mode 100644 index 0000000..2e60ad3 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -0,0 +1,489 @@ +import AppKit +import MenubucketCore +import SwiftUI +import UniformTypeIdentifiers + +/// BarShelf window ▸ Dock (R15): whether there is a BarShelf Dock and how it +/// relates to the Apple Dock, how it looks, and its profiles. +struct DockSettingsPage: View { + @ObservedObject var store: DockStore + @ObservedObject var runtime: WidgetRuntime + + @State private var selectedProfileID: String? + @State private var shortcuts: [String]? + @State private var linkDraft = "" + @State private var showLinkPrompt = false + @State private var newProfileName = "" + @State private var showNewProfilePrompt = false + @State private var profileToDelete: DockProfile? + + static let symbolPresets = [ + "square.grid.2x2", "briefcase", "house", "laptopcomputer", "hammer", + "paintbrush", "book", "gamecontroller", "music.note", "airplane", + "moon", "sun.max", "person.2", "graduationcap", "cup.and.saucer", + ] + + private var config: DockConfiguration { store.configuration } + + private var selectedProfile: DockProfile { + config.profiles.first { $0.id == selectedProfileID } ?? config.activeProfile + } + + var body: some View { + SettingsPage { + modeSection + if let error = store.lastError { + Section { + StatusBanner(tone: .critical, message: String(localized: "Couldn't save dock settings: \(error)")) + } + } + appearanceSection + profilesSection + itemsSection + appleDockSection + switchingSection + } + .alert("Add Link", isPresented: $showLinkPrompt) { + TextField("https://example.com", text: $linkDraft) + Button("Cancel", role: .cancel) { linkDraft = "" } + Button("Add") { addLink() } + } message: { + Text("A web page to open from the dock.") + } + .alert("New Profile", isPresented: $showNewProfilePrompt) { + TextField("Profile name", text: $newProfileName) + Button("Cancel", role: .cancel) { newProfileName = "" } + Button("Add") { + let name = newProfileName.trimmingCharacters(in: .whitespacesAndNewlines) + newProfileName = "" + guard !name.isEmpty else { return } + selectedProfileID = store.addProfile(named: name) + } + } message: { + Text("Starts empty. Add apps, folders, and widgets below.") + } + .alert( + "Delete \(profileToDelete?.name ?? "")?", + isPresented: Binding(get: { profileToDelete != nil }, set: { if !$0 { profileToDelete = nil } }) + ) { + Button("Cancel", role: .cancel) { profileToDelete = nil } + Button("Delete", role: .destructive) { + if let profile = profileToDelete { + store.removeProfile(profile.id) + selectedProfileID = nil + } + profileToDelete = nil + } + } message: { + Text("Its items and saved Apple Dock layout are removed. Nothing on disk is touched.") + } + } + + // MARK: Mode + + private var modeSection: some View { + Section { + Picker("BarShelf Dock", selection: Binding( + get: { config.mode }, + set: { mode in store.update { $0.mode = mode } } + )) { + Text("Off").tag(DockConfiguration.Mode.off) + Text("Alongside the Apple Dock").tag(DockConfiguration.Mode.alongside) + Text("Instead of the Apple Dock").tag(DockConfiguration.Mode.replace) + } + .pickerStyle(.radioGroup) + if config.mode == .replace { + StatusBanner( + tone: .info, + message: String(localized: "The Apple Dock is hidden while BarShelf runs and comes back when you quit BarShelf or turn this off. If it ever stays hidden, run barshelf dock restore-apple-dock in Terminal.") + ) + } else if config.mode == .alongside, sharesEdgeWithAppleDock { + StatusBanner( + tone: .warning, + message: String(localized: "The Apple Dock is on the same edge. The BarShelf Dock sits just above it; another position, or auto-hide, keeps them apart.") + ) + } + } header: { + Text("Dock") + } footer: { + Text("Profiles below also work with the BarShelf Dock off: they can switch the Apple Dock's apps.") + } + } + + private var sharesEdgeWithAppleDock: Bool { + store.appleDock.orientation == config.edge.rawValue && store.appleDock.visibility.autohide != true + } + + // MARK: Appearance + + private var appearanceSection: some View { + Section("Appearance") { + Picker("Style", selection: binding(\.style)) { + Text("Classic").tag(DockConfiguration.Style.classic) + Text("Shelf").tag(DockConfiguration.Style.shelf) + } + .pickerStyle(.segmented) + Text(config.style == .classic + ? "Like the Apple Dock: icons on glass, names on hover." + : "A sturdier bar with names under icons; roomy for widgets.") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Position on screen", selection: binding(\.edge)) { + Text("Left").tag(DockConfiguration.Edge.left) + Text("Bottom").tag(DockConfiguration.Edge.bottom) + Text("Right").tag(DockConfiguration.Edge.right) + } + .pickerStyle(.segmented) + LabeledContent("Icon size") { + Slider(value: binding(\.tileSize), in: DockConfiguration.tileSizeRange, step: 2) + .frame(maxWidth: 220) + } + LabeledContent("Widget size") { + Slider(value: binding(\.widgetSize), in: DockConfiguration.widgetSizeRange, step: 2) + .frame(maxWidth: 220) + } + Toggle("Magnification", isOn: binding(\.magnification)) + .disabled(config.style != .classic) + Toggle("Automatically hide and show the dock", isOn: binding(\.autoHide)) + Toggle("Show open apps that aren't in the dock", isOn: binding(\.showRunningApps)) + Toggle("Show Trash", isOn: binding(\.showTrash)) + } + .disabled(!config.mode.showsDock) + } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { store.configuration[keyPath: keyPath] }, + set: { value in store.update { $0[keyPath: keyPath] = value } } + ) + } + + // MARK: Profiles + + private var profilesSection: some View { + Section { + ForEach(Array(config.profiles.enumerated()), id: \.element.id) { index, profile in + profileRow(profile, position: index + 1) + } + .onMove { from, to in + store.update { $0.profiles.move(fromOffsets: from, toOffset: to) } + } + HStack { + Button("Add Profile…") { showNewProfilePrompt = true } + Button("Duplicate") { + selectedProfileID = store.addProfile( + named: String(localized: "\(selectedProfile.name) Copy"), copying: selectedProfile + ) + } + Spacer() + Button("Delete…", role: .destructive) { profileToDelete = selectedProfile } + .disabled(config.profiles.count < 2) + } + } header: { + Text("Profiles") + } footer: { + Text("Each profile has its own dock items, and can carry an Apple Dock layout and a popup page. Drag to reorder; the first nine get ⌃⌥1–9.") + } + } + + private func profileRow(_ profile: DockProfile, position: Int) -> some View { + let isSelected = profile.id == selectedProfile.id + let isActive = profile.id == config.activeProfileID + return HStack(spacing: Spacing.xs) { + Image(systemName: profile.symbol) + .frame(width: 20) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + Text(profile.name) + .fontWeight(isSelected ? .semibold : .regular) + if isActive { + Text("Active") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15), in: Capsule()) + } + Spacer() + if config.profileHotkeysEnabled, let label = DockHotkeys.label(forPosition: position) { + Text(label).font(.caption.monospaced()).foregroundStyle(.secondary) + } + if !isActive { + Button("Switch") { store.activate(profileID: profile.id) } + .controlSize(.small) + } + } + .contentShape(Rectangle()) + .onTapGesture { selectedProfileID = profile.id } + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + // MARK: Items of the selected profile + + private var itemsSection: some View { + let profile = selectedProfile + return Section { + TextField("Name", text: Binding( + get: { profile.name }, + set: { name in store.updateProfile(profile.id) { $0.name = name } } + )) + Picker("Symbol", selection: Binding( + get: { profile.symbol }, + set: { symbol in store.updateProfile(profile.id) { $0.symbol = symbol } } + )) { + ForEach(Self.symbolPresets, id: \.self) { symbol in + Label(symbol, systemImage: symbol).labelStyle(.iconOnly).tag(symbol) + } + if !Self.symbolPresets.contains(profile.symbol) { + Label(profile.symbol, systemImage: profile.symbol).tag(profile.symbol) + } + } + Picker("Popup page", selection: Binding( + get: { profile.popupPage ?? "" }, + set: { page in store.updateProfile(profile.id) { $0.popupPage = page.isEmpty ? nil : page } } + )) { + Text("Don't change").tag("") + ForEach(runtime.allGroups, id: \.self) { Text($0).tag($0) } + } + if profile.items.isEmpty { + Text("No items yet. Add some below, or drop apps, folders, and files on the dock.") + .foregroundStyle(.secondary) + } + ForEach(profile.items) { item in + itemRow(item, profileID: profile.id) + } + .onMove { from, to in + store.updateProfile(profile.id) { $0.items.move(fromOffsets: from, toOffset: to) } + } + addMenu(profileID: profile.id) + } header: { + Text("Items in \(profile.name)") + } + } + + private func itemRow(_ item: DockItem, profileID: String) -> some View { + HStack(spacing: Spacing.xs) { + itemIcon(item) + .frame(width: 22, height: 22) + VStack(alignment: .leading, spacing: 0) { + Text(itemTitle(item)).lineLimit(1) + Text(itemKind(item)).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + if case .folder(let path, let color, let label) = item.kind { + Picker("Color", selection: Binding( + get: { color }, + set: { store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: $0, label: label)), profileID: profileID) } + )) { + Text("Folder icon").tag(DockItem.FolderColor?.none) + ForEach(DockItem.FolderColor.allCases, id: \.self) { option in + Text(FolderBadge.name(option)).tag(Optional(option)) + } + } + .labelsHidden() + .fixedSize() + } + Button { + store.removeItem(item.id, profileID: profileID) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Remove from the dock") + .accessibilityLabel(Text("Remove \(itemTitle(item))")) + } + } + + @ViewBuilder + private func itemIcon(_ item: DockItem) -> some View { + switch item.kind { + case .widget: + Image(systemName: "square.grid.2x2").foregroundStyle(Color.accentColor) + case .spacer: + Image(systemName: "arrow.left.and.right").foregroundStyle(.secondary) + case .separator: + Image(systemName: "line.diagonal").foregroundStyle(.secondary) + case .link: + Image(systemName: "globe").foregroundStyle(.teal) + case .folder(_, let color?, let label): + FolderBadge(color: color, label: label ?? itemTitle(item)) + default: + if let icon = DockActions.icon(for: item) { + Image(nsImage: icon).resizable() + } else { + Image(systemName: "questionmark.app.dashed") + } + } + } + + private func itemTitle(_ item: DockItem) -> String { + if case .widget(let id) = item.kind { + return runtime.widgets.first { $0.id == id }?.displayName ?? id + } + return DockActions.displayName(for: item) + } + + private func itemKind(_ item: DockItem) -> String { + switch item.kind { + case .app: return String(localized: "App") + case .folder(let path, _, _): return path.abbreviatingWithTildeInPath + case .file(let path): return path.abbreviatingWithTildeInPath + case .link(let url, _): return url + case .shortcut: return String(localized: "Shortcut") + case .widget: return String(localized: "Widget") + case .spacer: return String(localized: "Space") + case .separator: return String(localized: "Divider") + } + } + + private func addMenu(profileID: String) -> some View { + HStack { + Menu("Add") { + Button("App…") { choose(profileID: profileID, apps: true) } + Button("Folder or File…") { choose(profileID: profileID, apps: false) } + Button("Link…") { + selectedProfileID = profileID + showLinkPrompt = true + } + Menu("Shortcut") { + if let shortcuts { + if shortcuts.isEmpty { Text("No Shortcuts found") } + ForEach(shortcuts, id: \.self) { name in + Button(name) { store.addItems([DockItem(kind: .shortcut(name: name))], profileID: profileID) } + } + } else { + Text("Loading…") + } + } + Menu("Widget") { + ForEach(runtime.widgets) { widget in + Button(widget.displayName) { + store.addItems([DockItem(kind: .widget(id: widget.id))], profileID: profileID) + } + } + } + Divider() + Button("Space") { store.addItems([DockItem(kind: .spacer)], profileID: profileID) } + Button("Divider") { store.addItems([DockItem(kind: .separator)], profileID: profileID) } + } + .fixedSize() + .onAppear { + guard shortcuts == nil else { return } + DockActions.listShortcuts { shortcuts = $0 } + } + Button("Copy from Apple Dock") { + store.addItems(store.appleDockItems(), profileID: profileID) + } + .help("Adds the Apple Dock's apps, folders, and files to this profile.") + Spacer() + } + } + + private func choose(profileID: String, apps: Bool) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = !apps + panel.canChooseFiles = true + panel.treatsFilePackagesAsDirectories = false + if apps { + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + } + panel.prompt = String(localized: "Add to Dock") + guard panel.runModal() == .OK else { return } + let items = panel.urls.map { url in + DockItem.forFile(at: url, isDirectory: (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false) + } + store.addItems(items, profileID: profileID) + } + + private func addLink() { + var text = linkDraft.trimmingCharacters(in: .whitespacesAndNewlines) + linkDraft = "" + guard !text.isEmpty else { return } + if !text.contains("://") { text = "https://" + text } + guard let url = URL(string: text), url.host != nil else { return } + store.addItems([DockItem(kind: .link(url: url.absoluteString, title: url.host))], profileID: selectedProfile.id) + } + + // MARK: Apple Dock layouts + + private var appleDockSection: some View { + let profile = selectedProfile + return Section { + Toggle("Switch the Apple Dock's apps with the profile", isOn: binding(\.appleDockLayouts)) + if let layout = profile.appleDock { + LabeledContent("Saved in \(profile.name)") { + VStack(alignment: .trailing, spacing: 2) { + Text(Self.summary(layout.appNames + layout.otherNames)) + .lineLimit(2) + .multilineTextAlignment(.trailing) + Text(layout.capturedAt, format: .dateTime.year().month().day().hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + Text("\(profile.name) has no Apple Dock layout saved; switching to it leaves the Apple Dock as it is.") + .foregroundStyle(.secondary) + } + HStack { + Button(profile.appleDock == nil ? "Save Current Apple Dock" : "Replace with Current Apple Dock") { + store.captureAppleDock(into: profile.id) + } + Button("Apply Now") { store.applyAppleDockLayout(of: profile.id) } + .disabled(profile.appleDock == nil) + Spacer() + Button("Forget") { store.updateProfile(profile.id) { $0.appleDock = nil } } + .disabled(profile.appleDock == nil) + } + } header: { + Text("Apple Dock Layout") + } footer: { + Text("Arrange the Apple Dock the way you want it for this profile, then save. Switching restarts the Dock briefly; open apps and windows stay as they are. The previous layout is backed up first.") + } + } + + static func summary(_ names: [String]) -> String { + let shown = names.prefix(6).joined(separator: ", ") + return names.count > 6 ? String(localized: "\(shown), and \(names.count - 6) more") : shown + } + + // MARK: Switching + + private var switchingSection: some View { + let profile = selectedProfile + let url = Self.switchURL(for: profile) + return Section { + Toggle("Switch profiles with ⌃⌥1–9", isOn: binding(\.profileHotkeysEnabled)) + Text("You can also swipe sideways with two fingers on the dock, or scroll over it holding ⌘, or pick a profile from the BarShelf menu.") + .font(.caption) + .foregroundStyle(.secondary) + LabeledContent("Link to \(profile.name)") { + HStack { + Text(url) + .font(.caption.monospaced()) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } + .controlSize(.small) + } + } + } header: { + Text("Switching") + } footer: { + Text("To follow a Focus: in Shortcuts, add a personal automation for when the Focus turns on, with the Open URLs action and this link. From Terminal: barshelf dock use \"\(profile.name)\".") + } + } + + static func switchURL(for profile: DockProfile) -> String { + let value = profile.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))) ?? profile.id + return "barshelf://dock?profile=\(value)" + } +} + +private extension String { + var abbreviatingWithTildeInPath: String { (self as NSString).abbreviatingWithTildeInPath } +} diff --git a/Sources/MenubucketApp/Dock/DockStore.swift b/Sources/MenubucketApp/Dock/DockStore.swift new file mode 100644 index 0000000..ef0a61b --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockStore.swift @@ -0,0 +1,239 @@ +import AppKit +import Combine +import MenubucketCore + +/// The BarShelf Dock's settings and profiles (R15), and the one place that +/// changes the Apple Dock: hiding it for `replace`, putting it back, and +/// rewriting its layout when a profile carries one. +final class DockStore: ObservableObject { + static let shared = DockStore() + + static var defaultFileURL: URL { + WidgetRuntime.applicationSupportDirectory.appendingPathComponent("dock.json") + } + + static var backupDirectory: URL { + WidgetRuntime.applicationSupportDirectory.appendingPathComponent("dock-backups", isDirectory: true) + } + + @Published private(set) var configuration: DockConfiguration + @Published private(set) var lastError: String? + /// The profile just switched to, for the dock's brief name banner. + @Published private(set) var announcedProfile: DockProfile? + + /// Runs after a profile becomes active (popup page, and so on). + var onProfileActivated: ((DockProfile) -> Void)? + + let appleDock: AppleDock + private let fileURL: URL + private var announceTask: Task? + + init( + fileURL: URL = DockStore.defaultFileURL, + appleDock: AppleDock = AppleDock(backupDirectory: DockStore.backupDirectory) + ) { + self.fileURL = fileURL + self.appleDock = appleDock + configuration = DockConfiguration.load(from: fileURL) + } + + var activeProfile: DockProfile { configuration.activeProfile } + + // MARK: Editing + + /// Applies an edit, saves it, and carries out what a mode change means + /// for the Apple Dock. + func update(_ change: (inout DockConfiguration) -> Void) { + var copy = configuration + change(©) + copy.normalize() + let oldMode = configuration.mode + configuration = copy + if oldMode != .replace, copy.mode == .replace { + hideAppleDock() + } else if oldMode == .replace, copy.mode != .replace { + restoreAppleDock() + } + save() + } + + func updateProfile(_ id: String, _ change: (inout DockProfile) -> Void) { + update { config in + guard let index = config.profiles.firstIndex(where: { $0.id == id }) else { return } + change(&config.profiles[index]) + } + } + + /// Adds items to the active profile, before `beforeID` or at the end. + func addItems(_ items: [DockItem], before beforeID: String? = nil, profileID: String? = nil) { + guard !items.isEmpty else { return } + updateProfile(profileID ?? configuration.activeProfileID) { profile in + let index = beforeID.flatMap { id in profile.items.firstIndex { $0.id == id } } ?? profile.items.count + profile.items.insert(contentsOf: items, at: index) + } + } + + func removeItem(_ itemID: String, profileID: String? = nil) { + updateProfile(profileID ?? configuration.activeProfileID) { profile in + profile.items.removeAll { $0.id == itemID } + } + } + + /// Moves an item in front of another (or to the end with nil). + func moveItem(_ itemID: String, before targetID: String?, profileID: String? = nil) { + guard itemID != targetID else { return } + updateProfile(profileID ?? configuration.activeProfileID) { profile in + guard let from = profile.items.firstIndex(where: { $0.id == itemID }) else { return } + let item = profile.items.remove(at: from) + let to = targetID.flatMap { id in profile.items.firstIndex { $0.id == id } } ?? profile.items.count + profile.items.insert(item, at: to) + } + } + + func replaceItem(_ item: DockItem, profileID: String? = nil) { + updateProfile(profileID ?? configuration.activeProfileID) { profile in + guard let index = profile.items.firstIndex(where: { $0.id == item.id }) else { return } + profile.items[index] = item + } + } + + @discardableResult + func addProfile(named name: String, copying source: DockProfile? = nil) -> String { + var profile = DockProfile(name: name) + if let source { + profile.symbol = source.symbol + profile.items = source.items.map { DockItem(kind: $0.kind) } + profile.appleDock = source.appleDock + profile.popupPage = source.popupPage + } + update { $0.profiles.append(profile) } + return profile.id + } + + func removeProfile(_ id: String) { + update { config in + guard config.profiles.count > 1 else { return } + config.profiles.removeAll { $0.id == id } + } + } + + // MARK: Switching + + /// Makes a profile active: the BarShelf Dock shows its items, the Apple + /// Dock takes its saved layout (when that is turned on), and the popup + /// moves to its page. + func activate(profileID: String) { + guard let profile = configuration.profiles.first(where: { $0.id == profileID }) else { return } + if configuration.activeProfileID != profileID { + update { $0.activeProfileID = profileID } + } + if configuration.appleDockLayouts, let layout = profile.appleDock { + appleDock.apply(layout) + } + onProfileActivated?(profile) + announce(profile) + } + + /// For URLs, the CLI, and Shortcuts: an id, a name, or a 1-based number. + @discardableResult + func activate(matching query: String) -> Bool { + guard let profile = configuration.profile(matching: query) else { return false } + activate(profileID: profile.id) + return true + } + + func activate(offset: Int) { + guard configuration.profiles.count > 1 else { return } + activate(profileID: configuration.profile(offsetFromActive: offset).id) + } + + private func announce(_ profile: DockProfile) { + announcedProfile = profile + announceTask?.cancel() + announceTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 1_400_000_000) + guard !Task.isCancelled else { return } + self?.announcedProfile = nil + } + } + + // MARK: Apple Dock layouts + + /// Saves what the Apple Dock shows now into a profile. + func captureAppleDock(into profileID: String) { + let layout = appleDock.currentLayout() + updateProfile(profileID) { $0.appleDock = layout } + } + + /// Puts a profile's saved layout into the Apple Dock now. + func applyAppleDockLayout(of profileID: String) { + guard let layout = configuration.profiles.first(where: { $0.id == profileID })?.appleDock else { return } + appleDock.apply(layout) + } + + /// The Apple Dock's apps, folders, and files as BarShelf Dock items. + func appleDockItems() -> [DockItem] { + let layout = appleDock.currentLayout() + func items(_ tiles: [Any]) -> [DockItem] { + tiles.compactMap { tile in + if AppleDockTiles.isSpacer(tile) { return DockItem(kind: .spacer) } + guard let url = AppleDockTiles.fileURL(of: tile) else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return nil } + return DockItem.forFile(at: url, isDirectory: isDirectory.boolValue) + } + } + let apps = items(layout.appTiles) + let others = items(layout.otherTiles) + return others.isEmpty ? apps : apps + [DockItem(kind: .separator)] + others + } + + // MARK: Hiding the Apple Dock (`replace`) + + private func hideAppleDock() { + guard let before = appleDock.hide() else { return } + // Only the first backup counts: it holds the user's own settings. + if configuration.appleDockBackup == nil { + configuration.appleDockBackup = before + } + } + + private func restoreAppleDock() { + guard let backup = configuration.appleDockBackup else { return } + appleDock.restore(backup) + configuration.appleDockBackup = nil + } + + /// At launch: `replace` hides the Apple Dock again (it was shown at quit, + /// or something put it back); anything else restores a backup left + /// behind by a crash. + func reconcileAppleDockAtLaunch() { + let before = configuration + if configuration.mode == .replace { + hideAppleDock() + } else { + restoreAppleDock() + } + // Someone who never opened the Dock page gets no dock.json. + if configuration != before { save() } + } + + /// At quit the Apple Dock comes back, so the Mac is never left without a + /// dock while BarShelf is not running. Launch hides it again. + func restoreAppleDockForTermination() { + guard configuration.mode == .replace, configuration.appleDockBackup != nil else { return } + restoreAppleDock() + save() + } + + // MARK: Persistence + + private func save() { + do { + try configuration.save(to: fileURL) + lastError = nil + } catch { + lastError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift new file mode 100644 index 0000000..2754fa1 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -0,0 +1,743 @@ +import AppKit +import MenubucketCore +import SwiftUI +import UniformTypeIdentifiers + +/// One thing drawn in the dock: a profile item, or something the dock adds +/// on its own (running apps, the Trash). +enum DockTile: Identifiable, Equatable { + case item(DockItem) + case running(RunningApps.App) + case divider(String) + case trash + + var id: String { + switch self { + case .item(let item): return item.id + case .running(let app): return "running:\(app.path)" + case .divider(let id): return "divider:\(id)" + case .trash: return "trash" + } + } + + /// Icons grow under the pointer; widgets, spacers, and dividers do not. + var magnifies: Bool { + switch self { + case .item(let item): + switch item.kind { + case .widget, .spacer, .separator: return false + default: return true + } + case .running, .trash: return true + case .divider: return false + } + } +} + +/// The BarShelf Dock's content (R15). +struct DockView: View { + @ObservedObject var store: DockStore + @ObservedObject var runtime: WidgetRuntime + @ObservedObject var running: RunningApps + /// The pointer came onto or left the icons; the panel measures the bar + /// only at rest, since magnified sizes would make it chase the pointer. + let onHoverChange: (Bool) -> Void + let onOpenSettings: () -> Void + + @State private var hoveredID: String? + @State private var dropTargetID: String? + + static let dragPrefix = "barshelf-dock-item:" + + private var config: DockConfiguration { store.configuration } + private var edge: DockConfiguration.Edge { config.edge } + private var tileSize: CGFloat { CGFloat(config.tileSize) } + private var isClassic: Bool { config.style == .classic } + private var spacing: CGFloat { isClassic ? max(2, tileSize * 0.08) : max(6, tileSize * 0.16) } + + // MARK: Tiles + + var tiles: [DockTile] { + Self.tiles(for: store.configuration, running: running.apps) + } + + /// The profile's items, then running apps not among them, then the Trash. + static func tiles(for config: DockConfiguration, running: [RunningApps.App]) -> [DockTile] { + let items = config.activeProfile.items + var tiles = items.map(DockTile.item) + if config.showRunningApps { + let pinned = Set(items.compactMap { item -> String? in + if case .app(let path) = item.kind { return RunningApps.key(path) } + return nil + }) + let extra = running.filter { !pinned.contains($0.path) && $0.bundleID != Bundle.main.bundleIdentifier } + if !extra.isEmpty { + if !tiles.isEmpty { tiles.append(.divider("running")) } + tiles += extra.map(DockTile.running) + } + } + if config.showTrash { + if !tiles.isEmpty { tiles.append(.divider("trash")) } + tiles.append(.trash) + } + return tiles + } + + private func scale(at index: Int, in tiles: [DockTile]) -> CGFloat { + guard isClassic, config.magnification, let hoveredID, + let hovered = tiles.firstIndex(where: { $0.id == hoveredID }), + tiles[hovered].magnifies, tiles[index].magnifies + else { return 1 } + let distance = CGFloat(abs(index - hovered)) + return 1 + Self.maxMagnification * max(0, 1 - distance / 2.5) + } + + static let maxMagnification: CGFloat = 0.5 + + // MARK: Body + + var body: some View { + let tiles = self.tiles + let layout = edge.isVertical + ? AnyLayout(VStackLayout(alignment: edge == .left ? .leading : .trailing, spacing: spacing)) + : AnyLayout(HStackLayout(alignment: .bottom, spacing: spacing)) + layout { + ForEach(Array(tiles.enumerated()), id: \.element.id) { index, tile in + tileView(tile, scale: scale(at: index, in: tiles)) + } + if tiles.isEmpty { emptyHint } + } + .padding(isClassic ? max(5, tileSize * 0.12) : max(8, tileSize * 0.18)) + .background { barBackground } + .contentShape(Rectangle()) + .contextMenu { barMenu } + .onDrop(of: DockDrop.acceptedTypes, isTargeted: nil) { providers in + DockDrop.receive(providers, store: store, before: nil) + } + .overlay(alignment: edge == .bottom ? .top : .center) { profileBanner } + .fixedSize() + .onChange(of: hoveredID == nil) { _, resting in onHoverChange(!resting) } + .animation(.spring(response: 0.22, dampingFraction: 0.82), value: hoveredID) + .animation(.easeInOut(duration: 0.2), value: tiles.map(\.id)) + // The panel is larger than the bar (room for magnified icons and + // labels); the bar sits on the screen edge inside it. + .padding(edgeInsets) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: edgeAlignment) + } + + private var edgeAlignment: Alignment { + switch edge { + case .bottom: return .bottom + case .left: return .leading + case .right: return .trailing + } + } + + private var edgeInsets: EdgeInsets { + let gap = DockPanelController.edgeGap + switch edge { + case .bottom: return EdgeInsets(top: 0, leading: 0, bottom: gap, trailing: 0) + case .left: return EdgeInsets(top: 0, leading: gap, bottom: 0, trailing: 0) + case .right: return EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: gap) + } + } + + @ViewBuilder + private var barBackground: some View { + let shape = RoundedRectangle(cornerRadius: isClassic ? tileSize * 0.36 : 18, style: .continuous) + if #available(macOS 26.0, *), isClassic { + shape.fill(.clear).glassEffect(.regular, in: shape) + } else { + shape + .fill(isClassic ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(.regularMaterial)) + .overlay(shape.strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5)) + .shadow(color: .black.opacity(0.18), radius: 10, y: 2) + } + } + + private var emptyHint: some View { + Text("Drop apps, folders, or files here") + .font(.callout) + .foregroundStyle(.secondary) + .frame(height: tileSize) + .padding(.horizontal, Spacing.s) + } + + @ViewBuilder + private var profileBanner: some View { + if let profile = store.announcedProfile { + Label(profile.name, systemImage: profile.symbol) + .font(.headline) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(.regularMaterial, in: Capsule()) + .offset(y: edge == .bottom ? -44 : 0) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + .allowsHitTesting(false) + .accessibilityLabel(Text("Dock profile: \(profile.name)")) + } + } + + // MARK: Tile views + + @ViewBuilder + private func tileView(_ tile: DockTile, scale: CGFloat) -> some View { + switch tile { + case .item(let item): + itemView(item, scale: scale) + case .running(let app): + iconTile( + id: tile.id, + title: FileManager.default.displayName(atPath: app.path) + .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]), + image: NSWorkspace.shared.icon(forFile: app.path), + isRunning: true, scale: scale, + action: { DockActions.openApp(path: app.path) }, + dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) } + ) + .contextMenu { runningAppMenu(app) } + case .divider: + divider + case .trash: + iconTile( + id: tile.id, title: String(localized: "Trash"), + image: NSImage(named: NSImage.trashEmptyName), isRunning: false, scale: scale, + action: DockActions.openTrash, + dropFiles: DockActions.moveToTrash + ) + .contextMenu { + Button("Open") { DockActions.openTrash() } + Divider() + settingsButton + } + } + } + + @ViewBuilder + private func itemView(_ item: DockItem, scale: CGFloat) -> some View { + Group { + switch item.kind { + case .widget(let id): + widgetTile(item: item, widgetID: id) + case .spacer: + Color.clear + .frame( + width: edge.isVertical ? tileSize : tileSize * 0.5, + height: edge.isVertical ? tileSize * 0.5 : tileSize + ) + .contentShape(Rectangle()) + case .separator: + divider + case .app(let path): + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: DockActions.icon(for: item), + isRunning: running.isRunning(path: path), scale: scale, + action: { DockActions.open(item) }, + dropFiles: { urls in DockActions.open(urls, withAppAt: path) } + ) + case .folder(let path, let color, let label): + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: color == nil ? DockActions.icon(for: item) : nil, + isRunning: false, scale: scale, + action: { Self.showFolderMenu(path: path) }, + dropFiles: nil, + custom: color.map { color in + AnyView(FolderBadge(color: color, label: label ?? DockActions.displayName(for: item))) + } + ) + case .link(let url, _): + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: nil, isRunning: false, scale: scale, + action: { DockActions.open(item) }, dropFiles: nil, + custom: AnyView(LinkBadge(url: url)) + ) + case .file, .shortcut: + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: DockActions.icon(for: item), isRunning: false, scale: scale, + action: { DockActions.open(item) }, dropFiles: nil + ) + } + } + .contextMenu { itemMenu(item) } + .onDrag { + NSItemProvider(object: "\(Self.dragPrefix)\(item.id)" as NSString) + } + .onDrop(of: DockDrop.acceptedTypes, isTargeted: Binding( + get: { dropTargetID == item.id }, + set: { dropTargetID = $0 ? item.id : (dropTargetID == item.id ? nil : dropTargetID) } + )) { providers in + DockDrop.receive(providers, store: store, before: item.id) + } + .overlay(alignment: edge.isVertical ? .top : .leading) { + if dropTargetID == item.id { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.accentColor) + .frame( + width: edge.isVertical ? nil : 3, + height: edge.isVertical ? 3 : nil + ) + .offset(x: edge.isVertical ? 0 : -spacing / 2 - 1.5, y: edge.isVertical ? -spacing / 2 - 1.5 : 0) + .allowsHitTesting(false) + } + } + } + + private var divider: some View { + Rectangle() + .fill(Color.primary.opacity(0.22)) + .frame( + width: edge.isVertical ? tileSize * 0.8 : 1, + height: edge.isVertical ? 1 : tileSize * 0.8 + ) + .padding(edge.isVertical ? .vertical : .horizontal, 2) + .frame(maxHeight: edge.isVertical ? nil : tileSize, alignment: .center) + } + + /// An app, folder, file, link, Shortcut, or the Trash. + private func iconTile( + id: String, + title: String, + image: NSImage?, + isRunning: Bool, + scale: CGFloat, + action: @escaping () -> Void, + dropFiles: (([URL]) -> Void)?, + custom: AnyView? = nil + ) -> some View { + let grown = tileSize * scale + let anchor: UnitPoint = switch edge { + case .bottom: .bottom + case .left: .leading + case .right: .trailing + } + return Button(action: action) { + VStack(spacing: 3) { + Group { + if let custom { + custom + } else if let image { + Image(nsImage: image).resizable().interpolation(.high) + } else { + Image(systemName: "questionmark.app.dashed").resizable().foregroundStyle(.secondary) + } + } + .frame(width: tileSize, height: tileSize) + .scaleEffect(scale, anchor: anchor) + .frame( + width: edge.isVertical ? tileSize : grown, + height: edge.isVertical ? grown : tileSize, + alignment: Alignment(horizontal: edge == .left ? .leading : edge == .right ? .trailing : .center, + vertical: edge == .bottom ? .bottom : .center) + ) + if !isClassic { + Text(title) + .font(.system(size: max(9, tileSize * 0.2))) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: tileSize * 1.3) + .foregroundStyle(.secondary) + } + } + .overlay(alignment: runningDotAlignment) { + if isRunning { + Circle() + .fill(Color.primary.opacity(0.75)) + .frame(width: 4, height: 4) + .offset(runningDotOffset) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(DockPressStyle()) + .overlay(alignment: labelAlignment) { + if isClassic, hoveredID == id { + HoverLabel(title: title) + .fixedSize() + .offset(labelOffset(grown: grown)) + .allowsHitTesting(false) + } + } + .onHover { inside in + if inside { hoveredID = id } else if hoveredID == id { hoveredID = nil } + } + .modifier(FileDropModifier(store: store, addsFolders: id != DockTile.trash.id, dropFiles: dropFiles)) + .accessibilityLabel(Text(title)) + .accessibilityValue(isRunning ? Text("Running") : Text("")) + } + + private var runningDotAlignment: Alignment { + switch edge { + case .bottom: return .bottom + case .left: return .leading + case .right: return .trailing + } + } + + private var runningDotOffset: CGSize { + let gap = isClassic ? max(4, tileSize * 0.1) : 2 + switch edge { + case .bottom: return CGSize(width: 0, height: isClassic ? gap : gap + 2) + case .left: return CGSize(width: -gap, height: 0) + case .right: return CGSize(width: gap, height: 0) + } + } + + private var labelAlignment: Alignment { + switch edge { + case .bottom: return .top + case .left: return .trailing + case .right: return .leading + } + } + + private func labelOffset(grown: CGFloat) -> CGSize { + let lift = grown - tileSize + 30 + switch edge { + case .bottom: return CGSize(width: 0, height: -lift) + case .left: return CGSize(width: lift + 40, height: 0) + case .right: return CGSize(width: -lift - 40, height: 0) + } + } + + // MARK: Widgets + + private func widgetFrame(for widgetID: String) -> CGSize { + let thickness = CGFloat(config.widgetSize) + let size = runtime.effectiveSize(for: widgetID) + if edge.isVertical { + let factor: CGFloat = switch size { + case "XS": 0.6 + case "S": 0.8 + case "L": 1.5 + default: 1.0 + } + return CGSize(width: thickness * 2, height: thickness * factor) + } + let factor: CGFloat = switch size { + case "XS", "S": 1.6 + case "L": 3.0 + default: 2.4 + } + return CGSize(width: thickness * factor, height: thickness) + } + + @ViewBuilder + private func widgetTile(item: DockItem, widgetID: String) -> some View { + let frame = widgetFrame(for: widgetID) + let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) + if let widget = runtime.widgets.first(where: { $0.id == widgetID }) { + WidgetCardView(widget: widget, runtime: runtime, compactHeight: frame.height, placement: .single) + .frame(width: frame.width, height: frame.height) + .clipShape(shape) + .overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) + } else { + VStack(spacing: Spacing.xxs) { + Image(systemName: "questionmark.square.dashed").font(.title2) + Text("Widget not installed").font(.caption) + } + .foregroundStyle(.secondary) + .frame(width: frame.width, height: frame.height) + .background(Color.primary.opacity(0.05), in: shape) + } + } + + // MARK: Menus + + @ViewBuilder + private func itemMenu(_ item: DockItem) -> some View { + switch item.kind { + case .app(let path): + Button("Open") { DockActions.open(item) } + if let app = running.runningApplication(path: path) { + Button("Hide") { app.hide() } + Button("Quit") { app.terminate() } + } + Button("Show in Finder") { DockActions.revealInFinder(path: path) } + case .folder(let path, let color, let label): + Button("Open") { DockActions.open(item) } + Button("Show in Finder") { DockActions.revealInFinder(path: path) } + Menu("Color") { + Button("None") { + store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: nil, label: label))) + } + ForEach(DockItem.FolderColor.allCases, id: \.self) { option in + Button(FolderBadge.name(option)) { + store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: option, label: label))) + } + .disabled(option == color) + } + } + case .file(let path): + Button("Open") { DockActions.open(item) } + Button("Show in Finder") { DockActions.revealInFinder(path: path) } + case .link(let url, _): + Button("Open") { DockActions.open(item) } + Button("Copy Link") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } + case .shortcut: + Button("Run Shortcut") { DockActions.open(item) } + Button("Open Shortcuts") { + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.shortcuts") { + DockActions.openApp(path: url.path) + } + } + case .widget(let id): + Button("Show in BarShelf") { DockActions.open(item) } + Button("Refresh") { runtime.refresh(widgetID: id) } + case .spacer, .separator: + EmptyView() + } + Divider() + Button("Remove from Dock", role: .destructive) { store.removeItem(item.id) } + Divider() + profilesMenu + settingsButton + } + + @ViewBuilder + private func runningAppMenu(_ app: RunningApps.App) -> some View { + Button("Keep in Dock") { + store.addItems([DockItem(kind: .app(path: app.path))]) + } + if let running = NSRunningApplication(processIdentifier: app.processID) { + Button("Hide") { running.hide() } + Button("Quit") { running.terminate() } + } + Button("Show in Finder") { DockActions.revealInFinder(path: app.path) } + Divider() + settingsButton + } + + @ViewBuilder + private var barMenu: some View { + profilesMenu + settingsButton + } + + @ViewBuilder + private var profilesMenu: some View { + if config.profiles.count > 1 { + Menu("Profile") { + ForEach(config.profiles) { profile in + Toggle(isOn: Binding( + get: { profile.id == config.activeProfileID }, + set: { _ in store.activate(profileID: profile.id) } + )) { + Label(profile.name, systemImage: profile.symbol) + } + } + } + } + } + + private var settingsButton: some View { + Button("Dock Settings…") { onOpenSettings() } + } + + static func showFolderMenu(path: String) { + DockActions.folderMenu(path: path).popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } +} + +// MARK: - Pieces + +private struct DockPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .brightness(configuration.isPressed ? -0.25 : 0) + } +} + +private struct HoverLabel: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 13)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.1), lineWidth: 0.5)) + } +} + +/// A folder drawn as a coloured tile with a letter or two. +struct FolderBadge: View { + let color: DockItem.FolderColor + let label: String + + static func color(_ color: DockItem.FolderColor) -> Color { + switch color { + case .blue: return .blue + case .purple: return .purple + case .pink: return .pink + case .red: return .red + case .orange: return .orange + case .yellow: return .yellow + case .green: return .green + case .gray: return .gray + } + } + + static func name(_ color: DockItem.FolderColor) -> String { + switch color { + case .blue: return String(localized: "Blue") + case .purple: return String(localized: "Purple") + case .pink: return String(localized: "Pink") + case .red: return String(localized: "Red") + case .orange: return String(localized: "Orange") + case .yellow: return String(localized: "Yellow") + case .green: return String(localized: "Green") + case .gray: return String(localized: "Gray") + } + } + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + let base = Self.color(color) + RoundedRectangle(cornerRadius: side * 0.24, style: .continuous) + .fill(LinearGradient(colors: [base.opacity(0.85), base], startPoint: .top, endPoint: .bottom)) + .overlay(alignment: .center) { + Text(String(label.prefix(2)).uppercased()) + .font(.system(size: side * 0.42, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .minimumScaleFactor(0.5) + .padding(side * 0.08) + } + .overlay(alignment: .topLeading) { + Image(systemName: "folder.fill") + .font(.system(size: side * 0.2)) + .foregroundStyle(.white.opacity(0.75)) + .padding(side * 0.1) + } + .padding(side * 0.06) + } + } +} + +/// A web link drawn as a globe tile with the site's first letter. +private struct LinkBadge: View { + let url: String + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + let host = URL(string: url)?.host?.replacingOccurrences(of: "www.", with: "") ?? url + RoundedRectangle(cornerRadius: side * 0.24, style: .continuous) + .fill(LinearGradient(colors: [.teal, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)) + .overlay { + Text(String(host.prefix(1)).uppercased()) + .font(.system(size: side * 0.46, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + .overlay(alignment: .bottomTrailing) { + Image(systemName: "globe") + .font(.system(size: side * 0.2, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + .padding(side * 0.1) + } + .padding(side * 0.06) + } + } +} + +// MARK: - Drag and drop + +/// Files dropped on an app open in it, or on the Trash go to it. Only tiles +/// that take files get the handler, so the rest pass drops to the dock. +private struct FileDropModifier: ViewModifier { + let store: DockStore + /// Apps and folders dropped on an app are being added, not opened; on + /// the Trash they are thrown away like anything else. + let addsFolders: Bool + let dropFiles: (([URL]) -> Void)? + + func body(content: Content) -> some View { + if let dropFiles { + content.onDrop(of: [.fileURL], isTargeted: nil) { providers in + DockDrop.loadFileURLs(providers) { urls in + if addsFolders, urls.allSatisfy(\.hasDirectoryPath) { + store.addItems(urls.map { DockItem.forFile(at: $0, isDirectory: true) }) + } else { + dropFiles(urls) + } + } + } + } else { + content + } + } +} + +enum DockDrop { + static let acceptedTypes: [UTType] = [.fileURL, .url, .text] + + /// Files become items, web links become link items, and a dragged dock + /// item moves. `before` is the item the drop landed on, if any. + static func receive(_ providers: [NSItemProvider], store: DockStore, before: String?) -> Bool { + if providers.contains(where: { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }) { + return loadFileURLs(providers) { urls in + let items = urls.map { url -> DockItem in + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return DockItem.forFile(at: url, isDirectory: isDirectory.boolValue) + } + store.addItems(items, before: before) + } + } + if let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) }) { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url, let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { return } + DispatchQueue.main.async { + store.addItems([DockItem(kind: .link(url: url.absoluteString, title: url.host))], before: before) + } + } + return true + } + if let provider = providers.first(where: { $0.canLoadObject(ofClass: NSString.self) }) { + _ = provider.loadObject(ofClass: NSString.self) { value, _ in + guard let text = value as? String, text.hasPrefix(DockView.dragPrefix) else { return } + let id = String(text.dropFirst(DockView.dragPrefix.count)) + DispatchQueue.main.async { store.moveItem(id, before: before) } + } + return true + } + return false + } + + /// Loads every file URL among the providers, then calls back on main. + @discardableResult + static func loadFileURLs(_ providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) -> Bool { + let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard !fileProviders.isEmpty else { return false } + let group = DispatchGroup() + let lock = NSLock() + var urls: [(Int, URL)] = [] + for (index, provider) in fileProviders.enumerated() { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { value, _ in + defer { group.leave() } + let url: URL? = if let data = value as? Data { + URL(dataRepresentation: data, relativeTo: nil) + } else { + value as? URL + } + guard let url else { return } + lock.lock() + urls.append((index, url)) + lock.unlock() + } + } + group.notify(queue: .main) { + completion(urls.sorted { $0.0 < $1.0 }.map(\.1)) + } + return true + } +} diff --git a/Sources/MenubucketApp/Hub/HubView.swift b/Sources/MenubucketApp/Hub/HubView.swift index a1f489d..cd9660e 100644 --- a/Sources/MenubucketApp/Hub/HubView.swift +++ b/Sources/MenubucketApp/Hub/HubView.swift @@ -76,6 +76,8 @@ struct HubView: View { ShelfView(runtime: runtime, model: model) case .menuBar: MenuBarPage(appPrefs: appPrefs, runtime: runtime) + case .dock: + DockSettingsPage(store: DockStore.shared, runtime: runtime) case .gallery: GalleryView(model: galleryModel, runtime: runtime) .onAppear { galleryModel.onWindowShown() } diff --git a/Sources/MenubucketApp/Hub/HubWindowController.swift b/Sources/MenubucketApp/Hub/HubWindowController.swift index af39068..8f06a04 100644 --- a/Sources/MenubucketApp/Hub/HubWindowController.swift +++ b/Sources/MenubucketApp/Hub/HubWindowController.swift @@ -9,14 +9,14 @@ import SwiftUI /// `widgets` keeps the Shelf's historical one. enum HubTab: String, CaseIterable, Identifiable { case shelf = "widgets" - case menuBar, gallery, create, automation + case menuBar, dock, gallery, create, automation case general, shortcuts, updates, privacy, advanced /// Former names, kept so existing callers read naturally. static let widgets = HubTab.shelf static let settings = HubTab.general - static let workspace: [HubTab] = [.shelf, .menuBar, .gallery, .create, .automation] + static let workspace: [HubTab] = [.shelf, .menuBar, .dock, .gallery, .create, .automation] static let settingsPages: [HubTab] = [.general, .shortcuts, .updates, .privacy, .advanced] var id: String { rawValue } @@ -25,6 +25,7 @@ enum HubTab: String, CaseIterable, Identifiable { switch self { case .shelf: return String(localized: "Shelf", comment: "Hub page: the widget shelf") case .menuBar: return String(localized: "Menu Bar") + case .dock: return String(localized: "Dock", comment: "Hub page: the BarShelf Dock") case .gallery: return String(localized: "Gallery") case .create: return String(localized: "Create", comment: "Hub page: build a widget") case .automation: return String(localized: "Automation") @@ -40,6 +41,7 @@ enum HubTab: String, CaseIterable, Identifiable { switch self { case .shelf: return "square.grid.2x2" case .menuBar: return "menubar.rectangle" + case .dock: return "dock.rectangle" case .gallery: return "sparkles.rectangle.stack" case .create: return "wand.and.stars" case .automation: return "keyboard" @@ -55,6 +57,7 @@ enum HubTab: String, CaseIterable, Identifiable { switch self { case .shelf: return String(localized: "Arrange your pages and widgets.") case .menuBar: return String(localized: "Choose what shows in the menu bar and how it looks.") + case .dock: return String(localized: "A dock for apps, folders, and widgets, with profiles to switch between.") case .gallery: return String(localized: "Find and install widgets.") case .create: return String(localized: "Build a widget from a command, a URL, a folder, or text.") case .automation: return String(localized: "Keyboard shortcuts and window control, imported from Hammerspoon.") diff --git a/Sources/MenubucketApp/Popover/ShelfMenus.swift b/Sources/MenubucketApp/Popover/ShelfMenus.swift index b678686..95aecd5 100644 --- a/Sources/MenubucketApp/Popover/ShelfMenus.swift +++ b/Sources/MenubucketApp/Popover/ShelfMenus.swift @@ -8,6 +8,7 @@ struct ShelfMoreMenu: View { let onCommand: (AppMenuCommand) -> Void /// So "Check for Updates…" turns into "Update to BarShelf X…". @ObservedObject private var updates = UpdateStatus.shared + @ObservedObject private var dockStore = DockStore.shared var body: some View { Menu { @@ -44,6 +45,22 @@ struct ShelfMoreMenu: View { Label(command.title, systemImage: command.symbol) } } + } else if command == .dock { + Menu { + let rows = AppMenu.dockProfiles(store: dockStore) + ForEach(rows) { row in + Toggle(isOn: Binding( + get: { row.isActive }, + set: { _ in dockStore.activate(profileID: row.id) } + )) { + Label(row.title, systemImage: row.symbol) + } + } + if !rows.isEmpty { Divider() } + Button(AppMenu.dockSettingsTitle) { onCommand(.dock) } + } label: { + Label(command.title, systemImage: command.symbol) + } } else { Button { onCommand(command) } label: { Label(command.title, systemImage: command.symbol) diff --git a/Sources/MenubucketApp/StatusItemController.swift b/Sources/MenubucketApp/StatusItemController.swift index 3287d44..1be8431 100644 --- a/Sources/MenubucketApp/StatusItemController.swift +++ b/Sources/MenubucketApp/StatusItemController.swift @@ -21,6 +21,9 @@ final class StatusItemController: NSObject { /// Draws the live strip into the main item and owns the extra status items /// of widgets the user split out. private var menuBar: MenuBarController! + /// The BarShelf Dock on the screen edge, and its profile hotkeys (R15). + private var dockPanel: DockPanelController! + private var dockHotkeys: DockHotkeys! private var keyboardMonitor: Any? private var scrollMonitor: Any? private var cancellables: Set = [] @@ -80,6 +83,20 @@ final class StatusItemController: NSObject { self?.runtime.handleURLRefreshTrigger(widgetID: widgetID) } + // barshelf://dock?profile=Work — Shortcuts' Focus automations use it. + WidgetInstaller.shared.onDockRequest = { [weak self] query in + self?.handleDockRequest(query) + } + let dockStore = DockStore.shared + dockStore.onProfileActivated = { [weak self] profile in + self?.showPopupPage(named: profile.popupPage) + } + dockPanel = DockPanelController(store: dockStore, runtime: runtime) + dockPanel.onOpenSettings = { + Task { @MainActor in HubWindowController.shared.show(tab: .dock) } + } + dockHotkeys = DockHotkeys(store: dockStore) + // Register the app's single runtime so runtime-less hub shims work. // Construction is guaranteed on the main thread (applicationDidFinishLaunching). MainActor.assumeIsolated { @@ -294,6 +311,10 @@ final class StatusItemController: NSObject { Task { @MainActor in HubWindowController.shared.show(tab: .gallery) } case .menuBar: break // a submenu, not an action + case .dock: + // The submenu's "Dock Settings…" row. + popup.hide() + Task { @MainActor in HubWindowController.shared.show(runtime: runtime, tab: .dock) } case .openBarShelf: openHub(nil) case .settings: @@ -442,6 +463,36 @@ final class StatusItemController: NSObject { runtime.updateMenuBarPlacement(for: widgetID) { $0.enabled = false } } + // MARK: - Dock (R15) + + private func handleDockRequest(_ query: [URLQueryItem]) { + let store = DockStore.shared + for item in query { + switch item.name.lowercased() { + case "profile": + if let value = item.value, !store.activate(matching: value) { + NSLog("BarShelf: no dock profile matches \(value)") + } + case "next": + store.activate(offset: 1) + case "previous", "prev": + store.activate(offset: -1) + default: + continue + } + return + } + } + + /// A profile's popup page, selected for the next time the popup opens + /// (or right away while it is open). + private func showPopupPage(named page: String?) { + guard let page else { return } + let pages = runtime.pages + guard let index = pages.firstIndex(where: { $0.group == page }) else { return } + pager.jump(to: index, pageCount: pages.count) + } + // MARK: - Global hotkey (Carbon RegisterEventHotKey — no a11y permission) /// Synchronizes externally loaded preferences. Interactive edits go diff --git a/Sources/MenubucketApp/WidgetInstaller.swift b/Sources/MenubucketApp/WidgetInstaller.swift index ec78ca9..7f00a00 100644 --- a/Sources/MenubucketApp/WidgetInstaller.swift +++ b/Sources/MenubucketApp/WidgetInstaller.swift @@ -274,6 +274,8 @@ final class WidgetInstaller { /// argument is the optional `widget` query item (`nil` → refresh all /// url-trigger widgets). var onRefreshRequest: ((_ widgetID: String?) -> Void)? + /// `barshelf://dock?profile=`, `?next`, `?previous` (R15). + var onDockRequest: ((_ query: [URLQueryItem]) -> Void)? /// Menu entry point: "Install Widget from URL…". func promptForURL() { @@ -303,6 +305,10 @@ final class WidgetInstaller { } return } + if route == "dock" { + onDockRequest?(components?.queryItems ?? []) + return + } if route == "refresh" { let widgetID = components?.queryItems? .first { $0.name == "widget" }? diff --git a/Sources/MenubucketApp/WidgetRuntime.swift b/Sources/MenubucketApp/WidgetRuntime.swift index ec9cbef..bb59b01 100644 --- a/Sources/MenubucketApp/WidgetRuntime.swift +++ b/Sources/MenubucketApp/WidgetRuntime.swift @@ -917,6 +917,9 @@ final class WidgetRuntime: ObservableObject { scheduler.configure(widgets: enabled) scheduler.setVisibleWidgetIDs(visibleWidgetIDs) syncMenuBar() + // `configure` may have dropped dock widgets it did not know yet. + dockWidgetIDs.formIntersection(seenIDs) + scheduler.setMenuBarWidgetIDs(menuBarWidgetIDs.union(dockWidgetIDs)) publishDesktopWidgetIndex() } @@ -1195,6 +1198,7 @@ final class WidgetRuntime: ObservableObject { scheduler.configure(widgets: widgets.filter { !prefs.isDisabled($0.id) }) setVisibleWidgetIDs(visibleWidgetIDs) syncMenuBar() + scheduler.setMenuBarWidgetIDs(menuBarWidgetIDs.union(dockWidgetIDs)) updateAttention() objectWillChange.send() if !flag, scriptDisableStopTokens[id] == nil { @@ -1487,7 +1491,7 @@ final class WidgetRuntime: ObservableObject { scheduler.setIntervalOverrides(intervalOverrides) let promoted = menuBarWidgetIDs guard promoted != previous else { return } - scheduler.setMenuBarWidgetIDs(promoted) + scheduler.setMenuBarWidgetIDs(promoted.union(dockWidgetIDs)) for id in promoted.subtracting(previous) { let widget = widgets.first { $0.id == id } let staleAfter = effectiveStaleAfter(widget?.manifest.refresh?.staleAfterSec) @@ -1656,11 +1660,35 @@ final class WidgetRuntime: ObservableObject { refreshWidgetsAwaitingVisibility() } + /// Widgets showing in the BarShelf Dock (R15). Like menu bar items they + /// are on screen whether or not the popup is open, so they poll at their + /// own cadence; an auto-hidden dock reports none. + private(set) var dockWidgetIDs: Set = [] + + func setDockWidgetIDs(_ ids: Set) { + let normalized = ids.intersection(widgets.map(\.id)) + guard normalized != dockWidgetIDs else { return } + let added = normalized.subtracting(dockWidgetIDs) + dockWidgetIDs = normalized + scheduler.setMenuBarWidgetIDs(menuBarWidgetIDs.union(normalized)) + for id in added { + guard let widget = widgets.first(where: { $0.id == id }) else { continue } + let snapshot = snapshots[id] ?? WidgetSnapshot(widgetID: id) + if snapshot.isStale(after: effectiveStaleAfter(widget.manifest.refresh?.staleAfterSec)) { + refresh(widget, manual: false) + } else if visibilityAwareWidgetIDs.contains(id), lastRefreshVisibility[id] == false { + refresh(widget, manual: false) + } + } + } + /// Whether this widget's card is on screen: the shelf is open on its page, - /// or it is showing in its own menu bar popover. Workflows read it as - /// `widget.visible` and use it to skip work nobody can see. + /// it is showing in its own menu bar popover, or it is in the dock. + /// Workflows read it as `widget.visible` and use it to skip work nobody + /// can see. func isCardVisible(_ widgetID: String) -> Bool { if menuBarPopoverWidgetID == widgetID { return true } + if dockWidgetIDs.contains(widgetID) { return true } return scheduler.popupIsOpen && visibleWidgetIDs.contains(widgetID) } diff --git a/Sources/MenubucketApp/main.swift b/Sources/MenubucketApp/main.swift index a087894..ecfcee7 100644 --- a/Sources/MenubucketApp/main.swift +++ b/Sources/MenubucketApp/main.swift @@ -23,6 +23,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { OnboardingWindowController.shared.showIfNeeded(runtime: runtime) } AutomationController.shared.startAtLaunch() + // `replace` hides the Apple Dock again; a crash's leftover backup is + // put back otherwise. + DockStore.shared.reconcileAppleDockAtLaunch() // Record that this build genuinely came up — after the status item // exists, so the receipt attests to the thing a user would look for // rather than to a process having been created. An updater waits for @@ -40,6 +43,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { AutomationController.shared.stopForTermination() + // Never leave the Mac without a dock while BarShelf is not running. + DockStore.shared.restoreAppleDockForTermination() } /// Minimal main menu carrying only the standard Edit commands. Not shown diff --git a/Sources/MenubucketCore/AppleDock.swift b/Sources/MenubucketCore/AppleDock.swift new file mode 100644 index 0000000..c6486b9 --- /dev/null +++ b/Sources/MenubucketCore/AppleDock.swift @@ -0,0 +1,315 @@ +import Foundation + +/// The Apple Dock's pinned items as saved into a dock profile (R15). +/// +/// The `persistent-apps` and `persistent-others` arrays are kept verbatim — +/// bookmark data and all — as binary property lists, so putting a layout back +/// loses nothing. The names are only for showing what a profile holds. +public struct AppleDockLayout: Codable, Equatable, Sendable { + public var apps: Data + public var others: Data + public var capturedAt: Date + public var appNames: [String] + public var otherNames: [String] + + public init(appTiles: [Any], otherTiles: [Any], capturedAt: Date = Date()) { + apps = Self.encode(appTiles) + others = Self.encode(otherTiles) + self.capturedAt = capturedAt + appNames = AppleDockTiles.labels(of: appTiles) + otherNames = AppleDockTiles.labels(of: otherTiles) + } + + public var appTiles: [Any] { Self.decode(apps) } + public var otherTiles: [Any] { Self.decode(others) } + + static func encode(_ tiles: [Any]) -> Data { + (try? PropertyListSerialization.data( + fromPropertyList: tiles, format: .binary, options: 0 + )) ?? Data() + } + + static func decode(_ data: Data) -> [Any] { + guard !data.isEmpty, + let value = try? PropertyListSerialization.propertyList(from: data, format: nil) + else { return [] } + return value as? [Any] ?? [] + } +} + +/// The Apple Dock's hide settings, as found before BarShelf hid it. `nil` +/// means the key was not set, which is how it is put back. +public struct AppleDockVisibility: Codable, Equatable, Sendable { + public var autohide: Bool? + public var autohideDelay: Double? + + public init(autohide: Bool?, autohideDelay: Double?) { + self.autohide = autohide + self.autohideDelay = autohideDelay + } +} + +/// Reading and building the Dock's tile dictionaries. Pure; no I/O. +public enum AppleDockTiles { + /// What a tile is called: its label, else the last part of its URL. + public static func labels(of tiles: [Any]) -> [String] { + tiles.compactMap { tile in + guard let tile = tile as? [String: Any] else { return nil } + let type = tile["tile-type"] as? String ?? "" + if type.hasSuffix("spacer-tile") { return nil } + let data = tile["tile-data"] as? [String: Any] ?? [:] + if let label = data["file-label"] as? String, !label.isEmpty { return label } + if let label = data["label"] as? String, !label.isEmpty { return label } + if let url = fileURL(of: tile) { + return (url.lastPathComponent as NSString).deletingPathExtension + } + return nil + } + } + + /// The file a tile points at, for app, file, and folder tiles. + public static func fileURL(of tile: Any) -> URL? { + guard let tile = tile as? [String: Any], + let data = tile["tile-data"] as? [String: Any], + let fileData = data["file-data"] as? [String: Any], + let string = fileData["_CFURLString"] as? String + else { return nil } + if let url = URL(string: string), url.isFileURL { return url } + return URL(fileURLWithPath: string) + } + + /// Whether the tile is a spacer of any width. + public static func isSpacer(_ tile: Any) -> Bool { + ((tile as? [String: Any])?["tile-type"] as? String)?.hasSuffix("spacer-tile") == true + } + + /// What tells two layouts apart: each tile's type and target, in order. + /// Bookkeeping the Dock rewrites on its own (GUIDs, dates) is left out, so + /// putting back a layout that is already showing is a no-op. + public static func signature(of tiles: [Any]) -> [String] { + tiles.map { tile in + let type = (tile as? [String: Any])?["tile-type"] as? String ?? "?" + let url = fileURL(of: tile)?.standardizedFileURL.path + ?? ((tile as? [String: Any])?["tile-data"] as? [String: Any])?["url"] + .flatMap { ($0 as? [String: Any])?["_CFURLString"] as? String } + ?? "" + return "\(type)|\(url)" + } + } + + /// A minimal tile for an app bundle — what dockutil writes; the Dock fills + /// in the rest on its next launch. + public static func appTile(path: String) -> [String: Any] { + [ + "tile-type": "file-tile", + "tile-data": [ + "file-data": [ + "_CFURLString": URL(fileURLWithPath: path, isDirectory: true).absoluteString, + "_CFURLStringType": 15, + ], + ], + ] + } + + /// A minimal folder tile for the Dock's right-hand side. + public static func folderTile(path: String) -> [String: Any] { + [ + "tile-type": "directory-tile", + "tile-data": [ + "file-data": [ + "_CFURLString": URL(fileURLWithPath: path, isDirectory: true).absoluteString, + "_CFURLStringType": 15, + ], + // Fan/grid by kind, sorted by date added, shown as a folder. + "arrangement": 2, + "displayas": 1, + "showas": 0, + ], + ] + } +} + +/// Where the Apple Dock's preferences are read and written. The live store +/// is `CFPreferences` for `com.apple.dock`; tests use a dictionary. +public protocol AppleDockDefaults: AnyObject { + func value(forKey key: String) -> Any? + func set(_ value: Any?, forKey key: String) + func synchronize() +} + +public final class SystemAppleDockDefaults: AppleDockDefaults { + private let domain = "com.apple.dock" as CFString + + public init() {} + + public func value(forKey key: String) -> Any? { + CFPreferencesCopyAppValue(key as CFString, domain) + } + + public func set(_ value: Any?, forKey key: String) { + CFPreferencesSetAppValue(key as CFString, value as CFPropertyList?, domain) + } + + public func synchronize() { + CFPreferencesAppSynchronize(domain) + } +} + +/// Reads, rewrites, hides, and restores the Apple Dock (R15). The Dock reads +/// its preferences at launch, so every change ends with a Dock restart +/// (launchd brings it straight back). +public final class AppleDock { + public static let appsKey = "persistent-apps" + public static let othersKey = "persistent-others" + public static let autohideKey = "autohide" + public static let autohideDelayKey = "autohide-delay" + public static let orientationKey = "orientation" + /// Long enough that the Dock never comes up in practice. + public static let hiddenDelay: Double = 1000 + /// Layout backups kept before each rewrite. + public static let backupLimit = 10 + + private let defaults: AppleDockDefaults + private let restart: () -> Void + private let backupDirectory: URL? + + public init( + defaults: AppleDockDefaults = SystemAppleDockDefaults(), + backupDirectory: URL? = nil, + restart: @escaping () -> Void = AppleDock.restartDock + ) { + self.defaults = defaults + self.backupDirectory = backupDirectory + self.restart = restart + } + + // MARK: Layout + + public func currentLayout(now: Date = Date()) -> AppleDockLayout { + defaults.synchronize() + return AppleDockLayout( + appTiles: defaults.value(forKey: Self.appsKey) as? [Any] ?? [], + otherTiles: defaults.value(forKey: Self.othersKey) as? [Any] ?? [], + capturedAt: now + ) + } + + /// Puts `layout` into the Apple Dock. Returns false, without restarting + /// the Dock, when it is already showing that layout. + @discardableResult + public func apply(_ layout: AppleDockLayout) -> Bool { + let current = currentLayout() + let newApps = layout.appTiles + let newOthers = layout.otherTiles + if AppleDockTiles.signature(of: current.appTiles) == AppleDockTiles.signature(of: newApps), + AppleDockTiles.signature(of: current.otherTiles) == AppleDockTiles.signature(of: newOthers) { + return false + } + writeBackup(current) + defaults.set(newApps, forKey: Self.appsKey) + defaults.set(newOthers, forKey: Self.othersKey) + defaults.synchronize() + restart() + return true + } + + /// Keeps the layout about to be replaced, newest last, at most + /// `backupLimit` files. + private func writeBackup(_ layout: AppleDockLayout) { + guard let backupDirectory else { return } + let fm = FileManager.default + try? fm.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + let tiles: [String: Any] = [ + Self.appsKey: layout.appTiles, + Self.othersKey: layout.otherTiles, + ] + let stamp = Int(layout.capturedAt.timeIntervalSince1970 * 1000) + // The stamp sorts the files; the suffix keeps two rewrites in the same + // millisecond from overwriting each other. + let suffix = UUID().uuidString.prefix(8) + let file = backupDirectory.appendingPathComponent("apple-dock-\(stamp)-\(suffix).plist") + if let data = try? PropertyListSerialization.data(fromPropertyList: tiles, format: .xml, options: 0) { + try? data.write(to: file, options: .atomic) + } + let existing = ((try? fm.contentsOfDirectory(at: backupDirectory, includingPropertiesForKeys: nil)) ?? []) + .filter { $0.lastPathComponent.hasPrefix("apple-dock-") && $0.pathExtension == "plist" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + for old in existing.dropLast(Self.backupLimit) { + try? fm.removeItem(at: old) + } + } + + // MARK: Visibility + + public var visibility: AppleDockVisibility { + defaults.synchronize() + return AppleDockVisibility( + autohide: Self.bool(defaults.value(forKey: Self.autohideKey)), + autohideDelay: Self.double(defaults.value(forKey: Self.autohideDelayKey)) + ) + } + + /// Whether the Dock is hidden the way `hide()` hides it. + public var isHidden: Bool { + let current = visibility + return current.autohide == true && (current.autohideDelay ?? 0) >= Self.hiddenDelay / 2 + } + + /// Hides the Apple Dock and returns its settings from before, to hand + /// back to `restore`. Already hidden → nil, nothing changes. + public func hide() -> AppleDockVisibility? { + guard !isHidden else { return nil } + let before = visibility + defaults.set(true, forKey: Self.autohideKey) + defaults.set(Self.hiddenDelay, forKey: Self.autohideDelayKey) + defaults.synchronize() + restart() + return before + } + + /// Puts back the hide settings `hide` found. + public func restore(_ backup: AppleDockVisibility) { + defaults.set(backup.autohide, forKey: Self.autohideKey) + defaults.set(backup.autohideDelay, forKey: Self.autohideDelayKey) + defaults.synchronize() + restart() + } + + /// Shows the Dock when there is no backup to go by: whatever hid it, the + /// long delay goes. Autohide itself stays as the user had it. + public func unhideWithoutBackup() { + guard isHidden else { return } + defaults.set(nil, forKey: Self.autohideDelayKey) + defaults.synchronize() + restart() + } + + /// "bottom", "left", or "right". + public var orientation: String { + defaults.synchronize() + return defaults.value(forKey: Self.orientationKey) as? String ?? "bottom" + } + + public static func restartDock() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + process.arguments = ["Dock"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() + process.waitUntilExit() + } + + private static func bool(_ value: Any?) -> Bool? { + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.boolValue } + if let string = value as? String { return ["1", "true", "yes"].contains(string.lowercased()) } + return nil + } + + private static func double(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } +} diff --git a/Sources/MenubucketCore/Dock.swift b/Sources/MenubucketCore/Dock.swift new file mode 100644 index 0000000..6b567d2 --- /dev/null +++ b/Sources/MenubucketCore/Dock.swift @@ -0,0 +1,449 @@ +import Foundation + +/// The BarShelf Dock (R15): a bar on a screen edge carrying apps, folders, +/// files, links, Shortcuts, and BarShelf widgets, arranged in profiles the +/// user switches between. Persisted at +/// `~/Library/Application Support/barshelf/dock.json`. +/// +/// Pure Codable model (UI-free, unit-testable). Missing keys decode to their +/// defaults and unknown item types are dropped, so files written by older or +/// newer builds keep loading. +public struct DockConfiguration: Codable, Equatable, Sendable { + /// How the BarShelf Dock relates to the Apple Dock. + public enum Mode: String, Codable, CaseIterable, Sendable { + /// No BarShelf Dock. Profiles still switch the Apple Dock layout when + /// that is turned on. + case off + /// The BarShelf Dock alongside the Apple Dock. + case alongside + /// The BarShelf Dock instead of the Apple Dock, which is hidden. + case replace + + public var showsDock: Bool { self != .off } + } + + public enum Style: String, Codable, CaseIterable, Sendable { + /// Like the Apple Dock: glass bar, icons, magnification. + case classic + /// A sturdier bar with labels, suited to widgets. + case shelf + } + + public enum Edge: String, Codable, CaseIterable, Sendable { + case bottom, left, right + + public var isVertical: Bool { self != .bottom } + } + + public var mode: Mode + public var style: Style + public var edge: Edge + /// App icon size in points. + public var tileSize: Double + /// How thick a widget tile is (its height on the bottom edge, its width on + /// a side edge), in points. + public var widgetSize: Double + public var magnification: Bool + /// Slide away until the pointer reaches the screen edge. + public var autoHide: Bool + /// Apps that are running but not in the profile, after a separator. + public var showRunningApps: Bool + public var showTrash: Bool + + public var profiles: [DockProfile] + public var activeProfileID: String + /// ⌃⌥1…9 switch to the first nine profiles. + public var profileHotkeysEnabled: Bool + /// Switching profiles also rewrites the Apple Dock's pinned items from + /// the profile's saved layout. + public var appleDockLayouts: Bool + /// The Apple Dock's own hide settings from before `replace` hid it. Kept + /// until they are put back, so a crash cannot strand the user without a + /// Dock. + public var appleDockBackup: AppleDockVisibility? + + public static let tileSizeRange: ClosedRange = 32...96 + public static let widgetSizeRange: ClosedRange = 72...220 + public static let defaultTileSize: Double = 52 + public static let defaultWidgetSize: Double = 110 + /// Profiles past this many cannot get a ⌃⌥ number. + public static let hotkeyProfileLimit = 9 + + public init( + mode: Mode = .off, + style: Style = .classic, + edge: Edge = .bottom, + tileSize: Double = DockConfiguration.defaultTileSize, + widgetSize: Double = DockConfiguration.defaultWidgetSize, + magnification: Bool = true, + autoHide: Bool = false, + showRunningApps: Bool = true, + showTrash: Bool = true, + profiles: [DockProfile] = [], + activeProfileID: String = "", + profileHotkeysEnabled: Bool = false, + appleDockLayouts: Bool = false, + appleDockBackup: AppleDockVisibility? = nil + ) { + self.mode = mode + self.style = style + self.edge = edge + self.tileSize = tileSize + self.widgetSize = widgetSize + self.magnification = magnification + self.autoHide = autoHide + self.showRunningApps = showRunningApps + self.showTrash = showTrash + self.profiles = profiles + self.activeProfileID = activeProfileID + self.profileHotkeysEnabled = profileHotkeysEnabled + self.appleDockLayouts = appleDockLayouts + self.appleDockBackup = appleDockBackup + normalize() + } + + /// Brings every field into its allowed range: sizes are clamped, there is + /// always at least one profile, ids are unique, and the active profile + /// exists. + public mutating func normalize() { + tileSize = Self.clamp(tileSize, to: Self.tileSizeRange, fallback: Self.defaultTileSize) + widgetSize = Self.clamp(widgetSize, to: Self.widgetSizeRange, fallback: Self.defaultWidgetSize) + if profiles.isEmpty { + profiles = [DockProfile(id: DockProfile.defaultID, name: DockProfile.defaultName)] + } + var seenProfiles: Set = [] + for index in profiles.indices { + profiles[index].normalize() + while !seenProfiles.insert(profiles[index].id).inserted { + profiles[index].id = UUID().uuidString + } + } + if !profiles.contains(where: { $0.id == activeProfileID }) { + activeProfileID = profiles[0].id + } + } + + private static func clamp(_ value: Double, to range: ClosedRange, fallback: Double) -> Double { + guard value.isFinite else { return fallback } + return min(max(value, range.lowerBound), range.upperBound) + } + + public var activeProfile: DockProfile { + profiles.first { $0.id == activeProfileID } ?? profiles[0] + } + + public var activeProfileIndex: Int { + profiles.firstIndex { $0.id == activeProfileID } ?? 0 + } + + /// Finds a profile the way a person names it on the command line or in a + /// Shortcut: its id, its name (any case), or its 1-based position. + public func profile(matching query: String) -> DockProfile? { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let byID = profiles.first(where: { $0.id == trimmed }) { return byID } + if let byName = profiles.first(where: { + $0.name.compare(trimmed, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { return byName } + if let position = Int(trimmed), profiles.indices.contains(position - 1) { + return profiles[position - 1] + } + return nil + } + + /// The profile `offset` steps from the active one, wrapping around. + public func profile(offsetFromActive offset: Int) -> DockProfile { + let count = profiles.count + let index = ((activeProfileIndex + offset) % count + count) % count + return profiles[index] + } + + /// The widgets the active profile places in the dock, in order. + public var activeWidgetIDs: [String] { + activeProfile.items.compactMap { item in + if case .widget(let id) = item.kind { return id } + return nil + } + } + + // MARK: Codable + + private enum CodingKeys: String, CodingKey { + case mode, style, edge, tileSize, widgetSize, magnification, autoHide + case showRunningApps, showTrash, profiles, activeProfileID + case profileHotkeysEnabled, appleDockLayouts, appleDockBackup + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let defaults = DockConfiguration() + mode = (try? c.decodeIfPresent(Mode.self, forKey: .mode)) ?? defaults.mode + style = (try? c.decodeIfPresent(Style.self, forKey: .style)) ?? defaults.style + edge = (try? c.decodeIfPresent(Edge.self, forKey: .edge)) ?? defaults.edge + tileSize = (try? c.decodeIfPresent(Double.self, forKey: .tileSize)) ?? defaults.tileSize + widgetSize = (try? c.decodeIfPresent(Double.self, forKey: .widgetSize)) ?? defaults.widgetSize + magnification = (try? c.decodeIfPresent(Bool.self, forKey: .magnification)) ?? defaults.magnification + autoHide = (try? c.decodeIfPresent(Bool.self, forKey: .autoHide)) ?? defaults.autoHide + showRunningApps = (try? c.decodeIfPresent(Bool.self, forKey: .showRunningApps)) ?? defaults.showRunningApps + showTrash = (try? c.decodeIfPresent(Bool.self, forKey: .showTrash)) ?? defaults.showTrash + profiles = (try? c.decodeIfPresent(LenientArray.self, forKey: .profiles))?.elements ?? [] + activeProfileID = (try? c.decodeIfPresent(String.self, forKey: .activeProfileID)) ?? "" + profileHotkeysEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .profileHotkeysEnabled)) ?? false + appleDockLayouts = (try? c.decodeIfPresent(Bool.self, forKey: .appleDockLayouts)) ?? false + appleDockBackup = (try? c.decodeIfPresent(AppleDockVisibility.self, forKey: .appleDockBackup)) ?? nil + normalize() + } + + // MARK: File persistence + + public static func load(from fileURL: URL) -> DockConfiguration { + guard let data = try? Data(contentsOf: fileURL), + let decoded = try? JSONDecoder().decode(DockConfiguration.self, from: data) + else { return DockConfiguration() } + return decoded + } + + /// Atomic write (creates the parent directory). + public func save(to fileURL: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: .atomic) + } +} + +/// One named arrangement: what the BarShelf Dock shows, and optionally the +/// Apple Dock layout and popup page that go with it. +public struct DockProfile: Codable, Equatable, Identifiable, Sendable { + public static let defaultID = "default" + public static let defaultName = "Default" + public static let defaultSymbol = "square.grid.2x2" + + public var id: String + public var name: String + /// SF Symbol shown in menus and the switcher. + public var symbol: String + public var items: [DockItem] + /// The Apple Dock's pinned items as saved into this profile. + public var appleDock: AppleDockLayout? + /// The popup page to show when this profile becomes active. + public var popupPage: String? + + public init( + id: String = UUID().uuidString, + name: String, + symbol: String = DockProfile.defaultSymbol, + items: [DockItem] = [], + appleDock: AppleDockLayout? = nil, + popupPage: String? = nil + ) { + self.id = id + self.name = name + self.symbol = symbol + self.items = items + self.appleDock = appleDock + self.popupPage = popupPage + normalize() + } + + public mutating func normalize() { + id = id.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty { id = UUID().uuidString } + name = name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { name = Self.defaultName } + symbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines) + if symbol.isEmpty { symbol = Self.defaultSymbol } + let page = popupPage?.trimmingCharacters(in: .whitespacesAndNewlines) + popupPage = (page?.isEmpty == false) ? page : nil + var seen: Set = [] + for index in items.indices { + while !seen.insert(items[index].id).inserted { + items[index].id = UUID().uuidString + } + } + } + + private enum CodingKeys: String, CodingKey { + case id, name, symbol, items, appleDock, popupPage + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = (try? c.decodeIfPresent(String.self, forKey: .name)) ?? Self.defaultName + symbol = (try? c.decodeIfPresent(String.self, forKey: .symbol)) ?? Self.defaultSymbol + items = (try? c.decodeIfPresent(LenientArray.self, forKey: .items))?.elements ?? [] + appleDock = (try? c.decodeIfPresent(AppleDockLayout.self, forKey: .appleDock)) ?? nil + popupPage = try? c.decodeIfPresent(String.self, forKey: .popupPage) + normalize() + } +} + +/// Something in the BarShelf Dock. +public struct DockItem: Codable, Equatable, Identifiable, Sendable { + public enum Kind: Equatable, Sendable { + case app(path: String) + /// A folder; `color` and `label` draw it as a coloured tile with a + /// letter instead of the folder icon. + case folder(path: String, color: FolderColor?, label: String?) + case file(path: String) + case link(url: String, title: String?) + /// A Shortcut from the Shortcuts app, run by name. + case shortcut(name: String) + /// A BarShelf widget, by instance id. + case widget(id: String) + case spacer + case separator + } + + public enum FolderColor: String, Codable, CaseIterable, Sendable { + case blue, purple, pink, red, orange, yellow, green, gray + } + + public var id: String + public var kind: Kind + + public init(id: String = UUID().uuidString, kind: Kind) { + self.id = id + self.kind = kind + } + + /// The item a dropped or chosen file URL becomes: an app bundle, a folder, + /// or a plain file. + public static func forFile(at url: URL, isDirectory: Bool) -> DockItem { + let path = url.standardizedFileURL.path + if url.pathExtension.lowercased() == "app" { + return DockItem(kind: .app(path: path)) + } + if isDirectory { + return DockItem(kind: .folder(path: path, color: nil, label: nil)) + } + return DockItem(kind: .file(path: path)) + } + + /// The file this item stands for, if any. + public var filePath: String? { + switch kind { + case .app(let path), .file(let path): return path + case .folder(let path, _, _): return path + default: return nil + } + } + + /// A name for menus and accessibility, without touching the disk. + public var fallbackTitle: String { + switch kind { + case .app(let path): + return ((path as NSString).lastPathComponent as NSString).deletingPathExtension + case .folder(let path, _, let label): + if let label, !label.isEmpty { return label } + return (path as NSString).lastPathComponent + case .file(let path): + return (path as NSString).lastPathComponent + case .link(let url, let title): + if let title, !title.isEmpty { return title } + return URL(string: url)?.host ?? url + case .shortcut(let name): return name + case .widget(let id): return id + case .spacer: return "Spacer" + case .separator: return "Separator" + } + } + + // MARK: Codable — flat `{ "id", "type", … }` + + private enum CodingKeys: String, CodingKey { + case id, type, path, color, label, url, title, name, widget + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = (try? c.decodeIfPresent(String.self, forKey: .id)) ?? UUID().uuidString + let type = try c.decode(String.self, forKey: .type) + switch type { + case "app": + kind = .app(path: try c.decode(String.self, forKey: .path)) + case "folder": + kind = .folder( + path: try c.decode(String.self, forKey: .path), + color: try? c.decodeIfPresent(FolderColor.self, forKey: .color), + label: try? c.decodeIfPresent(String.self, forKey: .label) + ) + case "file": + kind = .file(path: try c.decode(String.self, forKey: .path)) + case "link": + kind = .link( + url: try c.decode(String.self, forKey: .url), + title: try? c.decodeIfPresent(String.self, forKey: .title) + ) + case "shortcut": + kind = .shortcut(name: try c.decode(String.self, forKey: .name)) + case "widget": + kind = .widget(id: try c.decode(String.self, forKey: .widget)) + case "spacer": + kind = .spacer + case "separator": + kind = .separator + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: c, debugDescription: "Unknown dock item type \(type)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + switch kind { + case .app(let path): + try c.encode("app", forKey: .type) + try c.encode(path, forKey: .path) + case .folder(let path, let color, let label): + try c.encode("folder", forKey: .type) + try c.encode(path, forKey: .path) + try c.encodeIfPresent(color, forKey: .color) + try c.encodeIfPresent(label, forKey: .label) + case .file(let path): + try c.encode("file", forKey: .type) + try c.encode(path, forKey: .path) + case .link(let url, let title): + try c.encode("link", forKey: .type) + try c.encode(url, forKey: .url) + try c.encodeIfPresent(title, forKey: .title) + case .shortcut(let name): + try c.encode("shortcut", forKey: .type) + try c.encode(name, forKey: .name) + case .widget(let id): + try c.encode("widget", forKey: .type) + try c.encode(id, forKey: .widget) + case .spacer: + try c.encode("spacer", forKey: .type) + case .separator: + try c.encode("separator", forKey: .type) + } + } +} + +/// Decodes an array element by element, dropping the ones that fail, so one +/// item from a newer build does not cost the user the whole list. +struct LenientArray: Decodable { + let elements: [Element] + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var elements: [Element] = [] + while !container.isAtEnd { + if let element = try? container.decode(Element.self) { + elements.append(element) + } else { + // Any JSON value: advances past the element whatever it is. + _ = try? container.decode(JSONValue.self) + } + } + self.elements = elements + } +} diff --git a/Tests/BarShelfCLITests/DockCommandTests.swift b/Tests/BarShelfCLITests/DockCommandTests.swift new file mode 100644 index 0000000..bf25983 --- /dev/null +++ b/Tests/BarShelfCLITests/DockCommandTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import BarShelfKit +import MenubucketCore + +/// `barshelf dock` (R15). +final class DockCommandTests: XCTestCase { + private final class FakeDefaults: AppleDockDefaults { + var values: [String: Any] = [:] + func value(forKey key: String) -> Any? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func synchronize() {} + } + + private var fileURL: URL! + + override func setUpWithError() throws { + fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("dock-cli-\(UUID().uuidString).json") + try DockConfiguration(profiles: [ + DockProfile(id: "w", name: "Work"), + DockProfile(id: "p", name: "Personal"), + ]).save(to: fileURL) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: fileURL) + } + + func testListMarksTheActiveProfile() { + let text = DockCommand.list(DockConfiguration.load(from: fileURL)) + XCTAssertTrue(text.hasPrefix("* 1. Work")) + XCTAssertTrue(text.contains(" 2. Personal")) + } + + func testUseSendsTheProfileIDToTheApp() { + var opened: [URL] = [] + let status = DockCommand.run( + arguments: ["use", "personal"], configurationURL: fileURL, + openURL: { opened.append($0); return true } + ) + XCTAssertEqual(status, 0) + XCTAssertEqual(opened, [URL(string: "barshelf://dock?profile=p")!]) + } + + func testUseRejectsAnUnknownProfileWithoutCallingTheApp() { + var opened = false + let status = DockCommand.run( + arguments: ["use", "Gaming"], configurationURL: fileURL, + openURL: { _ in opened = true; return true } + ) + XCTAssertEqual(status, 1) + XCTAssertFalse(opened) + } + + func testRestoreAppleDockUsesTheBackupAndLeavesReplaceMode() throws { + var config = DockConfiguration.load(from: fileURL) + config.mode = .replace + config.appleDockBackup = AppleDockVisibility(autohide: false, autohideDelay: 0.2) + try config.save(to: fileURL) + let defaults = FakeDefaults() + defaults.values[AppleDock.autohideKey] = true + defaults.values[AppleDock.autohideDelayKey] = AppleDock.hiddenDelay + + let status = DockCommand.run( + arguments: ["restore-apple-dock"], configurationURL: fileURL, + appleDock: { AppleDock(defaults: defaults) {} } + ) + XCTAssertEqual(status, 0) + XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, false) + XCTAssertEqual(defaults.values[AppleDock.autohideDelayKey] as? Double, 0.2) + let saved = DockConfiguration.load(from: fileURL) + XCTAssertNil(saved.appleDockBackup) + XCTAssertEqual(saved.mode, .alongside) + } + + func testUnknownSubcommandFails() { + XCTAssertEqual(DockCommand.run(arguments: ["fly"], configurationURL: fileURL), 1) + XCTAssertEqual(DockCommand.run(arguments: [], configurationURL: fileURL), 1) + } +} diff --git a/Tests/MenubucketAppTests/AppCommandsTests.swift b/Tests/MenubucketAppTests/AppCommandsTests.swift index df688d9..cc38516 100644 --- a/Tests/MenubucketAppTests/AppCommandsTests.swift +++ b/Tests/MenubucketAppTests/AppCommandsTests.swift @@ -61,7 +61,7 @@ final class AppCommandsTests: XCTestCase { func testTheAppMenuHasTheDocumentedCommandsAndShortcuts() { XCTAssertEqual( AppMenu.sections, - [[.editShelf, .addWidget, .menuBar], [.openBarShelf, .settings, .checkForUpdates], [.quit]] + [[.editShelf, .addWidget, .menuBar, .dock], [.openBarShelf, .settings, .checkForUpdates], [.quit]] ) XCTAssertEqual(AppMenuCommand.editShelf.keyEquivalent, "e") // ⌘, is Settings, as everywhere on macOS; Open BarShelf opens the Shelf. diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift new file mode 100644 index 0000000..29d75c3 --- /dev/null +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import MenubucketApp +@testable import MenubucketCore + +/// The BarShelf Dock's store (R15): it is the only thing that hides, shows, +/// and rewrites the Apple Dock, so those transitions are pinned down here +/// against a fake `com.apple.dock`. +final class DockStoreTests: XCTestCase { + private final class FakeDefaults: AppleDockDefaults { + var values: [String: Any] = [:] + func value(forKey key: String) -> Any? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func synchronize() {} + } + + private var fileURL: URL! + private var defaults: FakeDefaults! + private var restarts = 0 + + override func setUp() { + fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("dock-\(UUID().uuidString).json") + defaults = FakeDefaults() + restarts = 0 + } + + override func tearDown() { + try? FileManager.default.removeItem(at: fileURL) + } + + private func makeStore() -> DockStore { + DockStore(fileURL: fileURL, appleDock: AppleDock(defaults: defaults) { [weak self] in self?.restarts += 1 }) + } + + func testReplaceHidesTheAppleDockAndOffPutsItBack() { + defaults.values[AppleDock.autohideKey] = false + let store = makeStore() + store.update { $0.mode = .replace } + XCTAssertTrue(store.appleDock.isHidden) + XCTAssertEqual(store.configuration.appleDockBackup, AppleDockVisibility(autohide: false, autohideDelay: nil)) + + store.update { $0.mode = .off } + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, false) + XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) + XCTAssertNil(store.configuration.appleDockBackup) + XCTAssertEqual(restarts, 2) + } + + func testOtherEditsDoNotTouchTheAppleDock() { + let store = makeStore() + store.update { $0.mode = .alongside } + store.update { $0.tileSize = 60 } + XCTAssertEqual(restarts, 0) + XCTAssertNil(defaults.values[AppleDock.autohideKey]) + } + + /// A crash while hiding leaves a backup in dock.json; the next launch + /// without `replace` must put the user's Dock back. + func testLaunchRestoresALeftoverBackup() throws { + var config = DockConfiguration(mode: .alongside) + config.appleDockBackup = AppleDockVisibility(autohide: nil, autohideDelay: nil) + try config.save(to: fileURL) + defaults.values[AppleDock.autohideKey] = true + defaults.values[AppleDock.autohideDelayKey] = AppleDock.hiddenDelay + + let store = makeStore() + store.reconcileAppleDockAtLaunch() + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertNil(store.configuration.appleDockBackup) + XCTAssertNil(DockConfiguration.load(from: fileURL).appleDockBackup, "the restore must be saved") + } + + func testQuitShowsTheAppleDockAndLaunchHidesItAgain() { + let store = makeStore() + store.update { $0.mode = .replace } + store.restoreAppleDockForTermination() + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertEqual(store.configuration.mode, .replace, "quitting is not turning the mode off") + + let relaunched = makeStore() + relaunched.reconcileAppleDockAtLaunch() + XCTAssertTrue(relaunched.appleDock.isHidden) + } + + func testSwitchingAppliesTheSavedAppleDockLayoutOnlyWhenTurnedOn() { + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Mail.app")] + let store = makeStore() + let work = store.addProfile(named: "Work") + store.captureAppleDock(into: work) + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Music.app")] + + store.activate(profileID: work) + XCTAssertEqual(store.configuration.activeProfileID, work) + XCTAssertEqual(restarts, 0, "Apple Dock layouts are off by default") + + store.update { $0.appleDockLayouts = true } + store.activate(profileID: work) + XCTAssertEqual(restarts, 1) + XCTAssertEqual(AppleDockTiles.labels(of: defaults.values[AppleDock.appsKey] as! [Any]), ["Mail"]) + } + + func testActivateByNameNumberAndOffset() { + let store = makeStore() + let work = store.addProfile(named: "Work") + var activated: [String] = [] + store.onProfileActivated = { activated.append($0.name) } + XCTAssertTrue(store.activate(matching: "work")) + XCTAssertFalse(store.activate(matching: "nope")) + XCTAssertEqual(store.configuration.activeProfileID, work) + store.activate(offset: 1) + XCTAssertEqual(store.configuration.activeProfileID, DockProfile.defaultID) + XCTAssertTrue(store.activate(matching: "2")) + XCTAssertEqual(activated, ["Work", DockProfile.defaultName, "Work"]) + } + + func testItemEditing() { + let store = makeStore() + let a = DockItem(kind: .app(path: "/Applications/A.app")) + let b = DockItem(kind: .app(path: "/Applications/B.app")) + let c = DockItem(kind: .separator) + store.addItems([a, b]) + store.addItems([c], before: b.id) + XCTAssertEqual(store.activeProfile.items.map(\.id), [a.id, c.id, b.id]) + store.moveItem(a.id, before: nil) + XCTAssertEqual(store.activeProfile.items.map(\.id), [c.id, b.id, a.id]) + store.removeItem(c.id) + XCTAssertEqual(store.activeProfile.items.map(\.id), [b.id, a.id]) + XCTAssertEqual(DockConfiguration.load(from: fileURL).activeProfile.items.map(\.id), [b.id, a.id]) + } + + func testTheLastProfileCannotBeRemoved() { + let store = makeStore() + store.removeProfile(DockProfile.defaultID) + XCTAssertEqual(store.configuration.profiles.count, 1) + } + + // MARK: Tiles + + func testTilesAddRunningAppsNotInTheProfileThenTrash() { + var config = DockConfiguration(mode: .alongside) + config.profiles[0].items = [DockItem(id: "mail", kind: .app(path: "/Applications/Mail.app"))] + let running = [ + RunningApps.App(path: "/Applications/Mail.app", bundleID: "com.apple.mail", processID: 1), + RunningApps.App(path: "/Applications/Notes.app", bundleID: "com.apple.Notes", processID: 2), + ] + let ids = DockView.tiles(for: config, running: running).map(\.id) + XCTAssertEqual(ids, ["mail", "divider:running", "running:/Applications/Notes.app", "divider:trash", "trash"]) + + config.showRunningApps = false + config.showTrash = false + XCTAssertEqual(DockView.tiles(for: config, running: running).map(\.id), ["mail"]) + } + + // MARK: URL + + func testDockDeepLinkRoutesToHook() { + let installer = WidgetInstaller() + var received: [[URLQueryItem]] = [] + installer.onDockRequest = { received.append($0) } + installer.handleDeepLink(URL(string: "barshelf://dock?profile=Work%20Mode")!) + installer.handleDeepLink(URL(string: "barshelf://dock?next")!) + XCTAssertEqual(received.count, 2) + XCTAssertEqual(received[0].first?.value, "Work Mode") + XCTAssertEqual(received[1].first?.name, "next") + } + + func testSwitchURLRoundTripsAName() { + let profile = DockProfile(id: "x", name: "Deep Work & Co") + let url = URL(string: DockSettingsPage.switchURL(for: profile))! + let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value + XCTAssertEqual(value, "Deep Work & Co") + } +} diff --git a/Tests/MenubucketCoreTests/DockConfigurationTests.swift b/Tests/MenubucketCoreTests/DockConfigurationTests.swift new file mode 100644 index 0000000..ea55d2f --- /dev/null +++ b/Tests/MenubucketCoreTests/DockConfigurationTests.swift @@ -0,0 +1,215 @@ +import XCTest +@testable import MenubucketCore + +final class DockConfigurationTests: XCTestCase { + private var tempDir: URL! + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("dock-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + func testDefaultsAreOffWithOneProfile() { + let config = DockConfiguration() + XCTAssertEqual(config.mode, .off) + XCTAssertEqual(config.profiles.count, 1) + XCTAssertEqual(config.activeProfileID, DockProfile.defaultID) + XCTAssertFalse(config.appleDockLayouts) + XCTAssertNil(config.appleDockBackup) + } + + func testRoundTripKeepsEveryItemKind() throws { + let items: [DockItem] = [ + DockItem(kind: .app(path: "/Applications/Safari.app")), + DockItem(kind: .folder(path: "/Users/me/Downloads", color: .orange, label: "D")), + DockItem(kind: .file(path: "/Users/me/notes.txt")), + DockItem(kind: .link(url: "https://example.com", title: "Example")), + DockItem(kind: .shortcut(name: "Start Day")), + DockItem(kind: .widget(id: "battery")), + DockItem(kind: .spacer), + DockItem(kind: .separator), + ] + let layout = AppleDockLayout( + appTiles: [AppleDockTiles.appTile(path: "/Applications/Safari.app")], + otherTiles: [], + capturedAt: Date(timeIntervalSince1970: 1_000) + ) + let config = DockConfiguration( + mode: .replace, style: .shelf, edge: .left, tileSize: 64, + profiles: [DockProfile(id: "work", name: "Work", items: items, appleDock: layout, popupPage: "Dev")], + activeProfileID: "work", profileHotkeysEnabled: true, appleDockLayouts: true, + appleDockBackup: AppleDockVisibility(autohide: false, autohideDelay: nil) + ) + let file = tempDir.appendingPathComponent("dock.json") + try config.save(to: file) + let loaded = DockConfiguration.load(from: file) + XCTAssertEqual(loaded, config) + XCTAssertEqual(loaded.activeProfile.appleDock?.appNames, ["Safari"]) + } + + func testUnknownItemTypesAreDroppedNotFatal() throws { + let json = """ + {"mode":"alongside","activeProfileID":"p","profiles":[{"id":"p","name":"P","items":[ + {"id":"a","type":"app","path":"/Applications/Mail.app"}, + {"id":"x","type":"hologram","path":"/nope"}, + "garbage", + {"id":"s","type":"separator"} + ]}]} + """ + let config = try JSONDecoder().decode(DockConfiguration.self, from: Data(json.utf8)) + XCTAssertEqual(config.mode, .alongside) + XCTAssertEqual(config.activeProfile.items.map(\.id), ["a", "s"]) + } + + func testUnknownModeFallsBackAndSizesClamp() throws { + let json = #"{"mode":"teleport","tileSize":4000,"widgetSize":-3}"# + let config = try JSONDecoder().decode(DockConfiguration.self, from: Data(json.utf8)) + XCTAssertEqual(config.mode, .off) + XCTAssertEqual(config.tileSize, DockConfiguration.tileSizeRange.upperBound) + XCTAssertEqual(config.widgetSize, DockConfiguration.widgetSizeRange.lowerBound) + } + + func testNormalizeFixesDuplicateIDsAndMissingActiveProfile() { + let config = DockConfiguration( + profiles: [ + DockProfile(id: "a", name: "One", items: [DockItem(id: "i", kind: .spacer), DockItem(id: "i", kind: .separator)]), + DockProfile(id: "a", name: "Two"), + ], + activeProfileID: "missing" + ) + XCTAssertEqual(Set(config.profiles.map(\.id)).count, 2) + XCTAssertEqual(Set(config.profiles[0].items.map(\.id)).count, 2) + XCTAssertEqual(config.activeProfileID, "a") + } + + func testProfileMatchingByIDNameOrPosition() { + let config = DockConfiguration(profiles: [ + DockProfile(id: "w", name: "Work"), + DockProfile(id: "p", name: "Personal"), + ]) + XCTAssertEqual(config.profile(matching: "p")?.name, "Personal") + XCTAssertEqual(config.profile(matching: "work")?.id, "w") + XCTAssertEqual(config.profile(matching: "2")?.id, "p") + XCTAssertNil(config.profile(matching: "3")) + XCTAssertNil(config.profile(matching: " ")) + } + + func testProfileOffsetWraps() { + var config = DockConfiguration(profiles: [ + DockProfile(id: "a", name: "A"), DockProfile(id: "b", name: "B"), DockProfile(id: "c", name: "C"), + ]) + XCTAssertEqual(config.profile(offsetFromActive: -1).id, "c") + config.activeProfileID = "c" + XCTAssertEqual(config.profile(offsetFromActive: 1).id, "a") + } + + func testItemForFile() { + XCTAssertEqual( + DockItem.forFile(at: URL(fileURLWithPath: "/Applications/Notes.app"), isDirectory: true).kind, + .app(path: "/Applications/Notes.app") + ) + XCTAssertEqual( + DockItem.forFile(at: URL(fileURLWithPath: "/tmp/dir"), isDirectory: true).kind, + .folder(path: "/tmp/dir", color: nil, label: nil) + ) + XCTAssertEqual( + DockItem.forFile(at: URL(fileURLWithPath: "/tmp/a.txt"), isDirectory: false).kind, + .file(path: "/tmp/a.txt") + ) + } +} + +final class AppleDockTests: XCTestCase { + private final class FakeDefaults: AppleDockDefaults { + var values: [String: Any] = [:] + func value(forKey key: String) -> Any? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func synchronize() {} + } + + private func tile(_ path: String, guid: Int) -> [String: Any] { + var tile = AppleDockTiles.appTile(path: path) + tile["GUID"] = guid + var data = tile["tile-data"] as! [String: Any] + data["file-label"] = ((path as NSString).lastPathComponent as NSString).deletingPathExtension + tile["tile-data"] = data + return tile + } + + func testCaptureAndApplyRestartsOnlyWhenDifferent() { + let defaults = FakeDefaults() + var restarts = 0 + let dock = AppleDock(defaults: defaults) { restarts += 1 } + defaults.values[AppleDock.appsKey] = [tile("/Applications/Mail.app", guid: 1)] + let work = dock.currentLayout() + XCTAssertEqual(work.appNames, ["Mail"]) + + // Same items with fresh bookkeeping: nothing to do. + defaults.values[AppleDock.appsKey] = [tile("/Applications/Mail.app", guid: 99)] + XCTAssertFalse(dock.apply(work)) + XCTAssertEqual(restarts, 0) + + defaults.values[AppleDock.appsKey] = [tile("/Applications/Music.app", guid: 2)] + XCTAssertTrue(dock.apply(work)) + XCTAssertEqual(restarts, 1) + XCTAssertEqual(AppleDockTiles.labels(of: defaults.values[AppleDock.appsKey] as! [Any]), ["Mail"]) + } + + func testApplyKeepsBoundedBackups() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("dock-backups-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let defaults = FakeDefaults() + let dock = AppleDock(defaults: defaults, backupDirectory: dir) {} + for index in 0..<(AppleDock.backupLimit + 3) { + let layout = AppleDockLayout( + appTiles: [tile("/Applications/App\(index).app", guid: index)], otherTiles: [], + capturedAt: Date(timeIntervalSince1970: Double(index)) + ) + dock.apply(layout) + } + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + XCTAssertEqual(files.count, AppleDock.backupLimit) + } + + func testHideAndRestorePutBackWhatWasThere() { + let defaults = FakeDefaults() + var restarts = 0 + let dock = AppleDock(defaults: defaults) { restarts += 1 } + XCTAssertFalse(dock.isHidden) + let backup = dock.hide() + XCTAssertEqual(backup, AppleDockVisibility(autohide: nil, autohideDelay: nil)) + XCTAssertTrue(dock.isHidden) + XCTAssertNil(dock.hide(), "hiding twice must not overwrite the real backup") + dock.restore(backup!) + XCTAssertNil(defaults.values[AppleDock.autohideKey]) + XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) + XCTAssertFalse(dock.isHidden) + XCTAssertEqual(restarts, 2) + } + + func testUnhideWithoutBackupKeepsAutohide() { + let defaults = FakeDefaults() + defaults.values[AppleDock.autohideKey] = true + defaults.values[AppleDock.autohideDelayKey] = 1000.0 + let dock = AppleDock(defaults: defaults) {} + dock.unhideWithoutBackup() + XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, true) + XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) + } + + func testLabelsSkipSpacers() { + let tiles: [Any] = [ + tile("/Applications/Mail.app", guid: 1), + ["tile-type": "small-spacer-tile", "tile-data": [String: Any]()], + AppleDockTiles.folderTile(path: "/Users/me/Downloads"), + ] + XCTAssertEqual(AppleDockTiles.labels(of: tiles), ["Mail", "Downloads"]) + XCTAssertTrue(AppleDockTiles.isSpacer(tiles[1])) + XCTAssertEqual(AppleDockTiles.fileURL(of: tiles[2])?.path, "/Users/me/Downloads") + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 6924f0b..794bd0b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -18,6 +18,7 @@ barshelf new [--kind exec|workflow|script] [--dir ] # 기본 kind barshelf validate # widget.json(+workflow.json 있으면) Core 디코더로 검증, 오류를 파일:필드 단위로 출력 barshelf pack [-o .mbw] # zip(.mbw) 생성 + 아카이브에 manifest.sha256 포함(widget.json의 sha256) barshelf list # 설치된 위젯 나열 (id, name, version, kind) +barshelf dock list|use |next|previous|restore-apple-dock # 독 프로필 전환, Apple Dock 복구 barshelf agent-spec # 위젯 작성 스펙(docs/AGENTS.md)을 stdout으로 출력 (LLM 에이전트용) barshelf upgrade [--check] [--yes] [--restart] [--app ] # CLI와 BarShelf.app을 최신 릴리스로 갱신 barshelf --version / --help @@ -32,6 +33,7 @@ barshelf --version / --help | `barshelf validate ` | 위젯 디렉터리 또는 pack된 `.mbw` 파일 | `widget.json`(그리고 `workflow.json`이 있으면 함께)을 Core 디코더로 검증하고, 오류를 파일:필드 단위로 출력한다. `.mbw`를 받으면 안전 추출 후 검증한다. | | `barshelf pack ` | `-o .mbw` (출력 파일명) | 위젯 디렉터리를 zip(`.mbw`)으로 패키징하고, 아카이브에 `manifest.sha256`(widget.json의 sha256)을 포함한다. | | `barshelf list` | — | 설치된 위젯을 id, name, version, kind로 나열한다. | +| `barshelf dock` | `list`, `use <프로필>`, `next`, `previous`, `restore-apple-dock` | 독 프로필([`docs/DOCK.md`](DOCK.md))을 나열하고 전환한다. `use`는 이름·id·번호를 받아 `barshelf://dock?profile=`로 실행 중인 앱에 전달한다. `restore-apple-dock`은 앱 없이도 숨겨진 Apple Dock을 되돌린다. | | `barshelf agent-spec` | — | 위젯 작성 스펙([`docs/AGENTS.md`](AGENTS.md))을 stdout으로 출력한다. LLM 에이전트에게 위젯 제작 계약 전체를 한 번에 넘길 때 쓴다. 개발 체크아웃에서는 디스크의 `docs/AGENTS.md`를, 단독 배포 바이너리에서는 빌드시 내장된 사본을 출력한다(내용 동일). | | `barshelf upgrade` | `--check`(확인만), `--yes`(확인 프롬프트 생략), `--restart`(앱 재시작), `--app ` | CLI(`barshelf`·`bsf`)와 `BarShelf.app`을 최신 GitHub 릴리스로 갱신한다. 자세한 내용은 아래 [자가 업데이트](#자가-업데이트). | | `barshelf --version` / `barshelf --help` | — | 버전/도움말 출력. | diff --git a/docs/DOCK.md b/docs/DOCK.md new file mode 100644 index 0000000..4455461 --- /dev/null +++ b/docs/DOCK.md @@ -0,0 +1,93 @@ +# BarShelf Dock + +BarShelf is menu-bar-first; the dock is an option, off by default. Open the +BarShelf window's **Dock** page to turn it on. + +## Modes + +| Mode | What happens | +| --- | --- | +| **Off** | No BarShelf Dock. Profiles can still switch the Apple Dock's apps (below). | +| **Alongside the Apple Dock** | The BarShelf Dock sits on its edge next to the Apple Dock. On the same edge it sits just above it. | +| **Instead of the Apple Dock** | The Apple Dock is hidden while BarShelf runs. | + +Hiding the Apple Dock uses the only method macOS allows: BarShelf turns on its +auto-hide with a very long delay, then restarts the Dock. First it saves your +own `autohide` and `autohide-delay` settings to `dock.json`, and it puts them +back when you switch modes or quit BarShelf. If BarShelf crashes, the next +launch puts them back too. If the Apple Dock ever stays hidden anyway: + +```bash +barshelf dock restore-apple-dock +``` + +macOS has no public way to make other apps' windows stay clear of a dock that +isn't Apple's. Windows can go under the BarShelf Dock, as they do under an +auto-hidden Apple Dock. Turn on **Automatically hide and show the dock** if +that gets in the way. The dock then slides in when the pointer reaches the +screen edge. + +## What goes in the dock + +- **Apps**: click to open or bring forward. Drop files on an app to open them + with it. A dot means the app is running. Apps that are open but not in the + profile come after a divider (optional). +- **Folders**: click for a menu of the folder's contents, with subfolders as + submenus. A folder can be a coloured tile with a letter or two. +- **Files** and **links**: open on click. +- **Shortcuts**: run on click, through the `shortcuts` tool. +- **Widgets**: any BarShelf widget, live. A dock widget refreshes on its own + schedule even while the popup is closed, as a menu bar item does. An + auto-hidden dock stops refreshing until it shows again. +- **Spaces** and **dividers**, and the **Trash** (drop files on it to throw them away). + +Drag apps, folders, files, or web links onto the dock to add them. Drag an +item onto another to move it. Right-click any item for its menu, including +**Remove from Dock**. + +**Classic** looks like the Apple Dock: icons on glass (Liquid Glass on macOS +26 and later), names on hover, and optional magnification. **Shelf** is a +sturdier bar with names under icons, with more room for widgets. + +## Profiles + +Each profile has its own items. It can also carry: + +- **An Apple Dock layout.** Arrange the Apple Dock, then click **Save Current + Apple Dock**. With **Switch the Apple Dock's apps with the profile** on, + switching profiles rewrites the Apple Dock's pinned apps and folders and + restarts the Dock for a moment. Open apps and windows aren't affected. The + layout being replaced goes to `dock-backups/` first (the last ten are kept). +- **A popup page**, which the BarShelf popup turns to. + +Ways to switch: + +- **⌃⌥1–9** for the first nine profiles (turn on in **Switching**). +- A **two-finger sideways swipe** on the dock, or **⌘-scroll** over it. +- **BarShelf menu ▸ Dock**, or right-click the dock. +- A link: `barshelf://dock?profile=Work` (name, id, or number), + `barshelf://dock?next`, `barshelf://dock?previous`. +- Terminal: `barshelf dock use Work`, `barshelf dock next`, `barshelf dock list`. + +### Following a Focus + +macOS doesn't tell other apps which Focus is on, so this goes through Shortcuts: + +1. Open **Shortcuts ▸ Automation ▸ New Automation ▸ Focus**, pick the Focus, + and choose **When Turning On** and **Run Immediately**. +2. Add the **Open URLs** action with the profile's link. **Dock ▸ Switching** + shows it, with a Copy button. +3. To go back when the Focus ends, add a **When Turning Off** automation with + the other profile's link. + +## Files + +| Path (under `~/Library/Application Support/barshelf/`) | Contents | +| --- | --- | +| `dock.json` | Mode, look, profiles, and the Apple Dock backup while it is hidden | +| `dock-backups/apple-dock-*.plist` | The Apple Dock layouts replaced by profile switches | + +## Not possible (no public API) + +Badges on app icons (unread counts), window previews, and minimised windows in +the dock. From 336840a0e810594b65f4f1029b10b3606252c456 Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 15:50:52 +0900 Subject: [PATCH 02/10] fix(dock): tile menus answer AXShowMenu, for VoiceOver and automation Testing the dock through the accessibility API (nlc) showed the tiles' right-click menus could not be opened by AXShowMenu: SwiftUI's context menu does not answer it in the non-activating panel, so VoiceOver's VO-Shift-M and any automation had no way to Remove from Dock, Quit, or switch profiles from the dock. Each tile's menu is now one list of entries drawn twice: as the context menu and as an NSMenu popped for AXShowMenu (after the action returns, so the call is not held open by menu tracking). Co-Authored-By: Claude Opus 5.5 (1M context) --- .context/nlc/barshelf_dock_menu_probe.json | 13 ++ .context/nlc/barshelf_dock_open_notes.json | 15 ++ .context/nlc/barshelf_open_dock_settings.json | 14 ++ Sources/MenubucketApp/Dock/DockMenus.swift | 152 ++++++++++++++++++ .../Dock/DockPanelController.swift | 5 + Sources/MenubucketApp/Dock/DockView.swift | 127 +++++++-------- Tests/MenubucketAppTests/DockStoreTests.swift | 29 ++++ 7 files changed, 285 insertions(+), 70 deletions(-) create mode 100644 .context/nlc/barshelf_dock_menu_probe.json create mode 100644 .context/nlc/barshelf_dock_open_notes.json create mode 100644 .context/nlc/barshelf_open_dock_settings.json create mode 100644 Sources/MenubucketApp/Dock/DockMenus.swift diff --git a/.context/nlc/barshelf_dock_menu_probe.json b/.context/nlc/barshelf_dock_menu_probe.json new file mode 100644 index 0000000..6c8e7a2 --- /dev/null +++ b/.context/nlc/barshelf_dock_menu_probe.json @@ -0,0 +1,13 @@ +{ + "name": "barshelf_dock_menu_probe", + "target": "com.barshelf.app", + "steps": [ + { "action": "secondary_action", + "selector": { "role": "Button", "desc": "Calendar" }, + "params": { "action": "AXShowMenu" }, + "intent_text": "독의 캘린더 우클릭 메뉴 열기", + "unverified_ok": true, + "safety": { "effects": ["read"], "reversibility": "reversible", + "resource": { "object_type": "컨텍스트 메뉴", "scope": "entry" } } } + ] +} diff --git a/.context/nlc/barshelf_dock_open_notes.json b/.context/nlc/barshelf_dock_open_notes.json new file mode 100644 index 0000000..a2b6dde --- /dev/null +++ b/.context/nlc/barshelf_dock_open_notes.json @@ -0,0 +1,15 @@ +{ + "name": "barshelf_dock_open_notes", + "target": "com.barshelf.app", + "steps": [ + { "action": "click", + "selector": { "role": "Button", "desc": "Notes" }, + "intent_text": "BarShelf Dock에서 메모 앱 열기", + "postcondition": { "field": { "role": "Button", "desc": "Notes" }, + "equals": "Running", "attribute": "Value" }, + "safety": { "effects": ["write_ephemeral"], "reversibility": "reversible", + "resource": { "object_type": "앱 실행", "scope": "item" }, + "evidence": [{ "source": "user_intent", "field": "asked", + "value": "실제로 테스트도해주세요 … 한번확인해서 잘작동되나해보세요" }] } } + ] +} diff --git a/.context/nlc/barshelf_open_dock_settings.json b/.context/nlc/barshelf_open_dock_settings.json new file mode 100644 index 0000000..df2716e --- /dev/null +++ b/.context/nlc/barshelf_open_dock_settings.json @@ -0,0 +1,14 @@ +{ + "name": "barshelf_open_dock_settings", + "target": "com.barshelf.app", + "steps": [ + { "action": "click", + "selector": { "role": "StaticText", "value": "Dock", "ancestor": "Sidebar", + "in_focused_window": true }, + "click_ancestor": ["Row", "Cell"], + "intent_text": "BarShelf 창에서 Dock 페이지 열기", + "postcondition": { "contains": "Alongside the Apple Dock" }, + "safety": { "effects": ["read"], "reversibility": "reversible", + "resource": { "object_type": "설정 페이지 탐색", "scope": "entry" } } } + ] +} diff --git a/Sources/MenubucketApp/Dock/DockMenus.swift b/Sources/MenubucketApp/Dock/DockMenus.swift new file mode 100644 index 0000000..ff1001c --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockMenus.swift @@ -0,0 +1,152 @@ +import AppKit +import SwiftUI + +/// One row of a dock tile's menu. A tile's menu is defined once, as these, +/// and drawn two ways: as the SwiftUI context menu a right-click opens, and +/// as an `NSMenu` for `AXShowMenu`. SwiftUI's context menu does not answer +/// that accessibility action in the dock's non-activating panel, which left +/// VoiceOver (VO-Shift-M) and automation with no way to reach these commands. +struct DockMenuEntry: Identifiable { + enum Kind { + case action(() -> Void) + case submenu([DockMenuEntry]) + case divider + } + + let id = UUID() + let title: String + var symbol: String? + var isChecked = false + var isEnabled = true + var isDestructive = false + let kind: Kind + + static func action( + _ title: String, symbol: String? = nil, checked: Bool = false, + enabled: Bool = true, destructive: Bool = false, _ run: @escaping () -> Void + ) -> DockMenuEntry { + DockMenuEntry( + title: title, symbol: symbol, isChecked: checked, isEnabled: enabled, + isDestructive: destructive, kind: .action(run) + ) + } + + static func submenu(_ title: String, _ entries: [DockMenuEntry]) -> DockMenuEntry { + DockMenuEntry(title: title, kind: .submenu(entries)) + } + + static var divider: DockMenuEntry { DockMenuEntry(title: "", kind: .divider) } + + /// Leading, trailing, and doubled dividers dropped, so optional groups can + /// be appended without bookkeeping. + static func tidy(_ entries: [DockMenuEntry]) -> [DockMenuEntry] { + var out: [DockMenuEntry] = [] + for entry in entries { + if case .divider = entry.kind { + if out.isEmpty { continue } + if case .divider = out.last!.kind { continue } + } + out.append(entry) + } + if let last = out.last, case .divider = last.kind { out.removeLast() } + return out + } +} + +/// The SwiftUI rendering, for `.contextMenu`. +struct DockMenuContent: View { + let entries: [DockMenuEntry] + + var body: some View { + ForEach(entries) { entry in + switch entry.kind { + case .divider: + Divider() + case .submenu(let children): + Menu(entry.title) { DockMenuContent(entries: children) } + case .action(let run): + if entry.isChecked { + Toggle(isOn: Binding(get: { true }, set: { _ in run() })) { label(entry) } + .disabled(!entry.isEnabled) + } else { + Button(role: entry.isDestructive ? .destructive : nil, action: run) { label(entry) } + .disabled(!entry.isEnabled) + } + } + } + } + + @ViewBuilder + private func label(_ entry: DockMenuEntry) -> some View { + if let symbol = entry.symbol { + Label(entry.title, systemImage: symbol) + } else { + Text(entry.title) + } + } +} + +/// The AppKit rendering, popped up for `AXShowMenu`. +enum DockMenuPresenter { + /// Keeps the closures alive while the menu is open. + private final class Target: NSObject { + let run: () -> Void + init(_ run: @escaping () -> Void) { self.run = run } + @objc func fire(_ sender: Any?) { run() } + } + + static func makeMenu(_ entries: [DockMenuEntry]) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + for entry in entries { + switch entry.kind { + case .divider: + menu.addItem(.separator()) + case .submenu(let children): + let item = NSMenuItem(title: entry.title, action: nil, keyEquivalent: "") + item.submenu = makeMenu(children) + menu.addItem(item) + case .action(let run): + let target = Target(run) + let item = NSMenuItem(title: entry.title, action: #selector(Target.fire(_:)), keyEquivalent: "") + item.target = target + item.representedObject = target + item.isEnabled = entry.isEnabled + item.state = entry.isChecked ? .on : .off + if let symbol = entry.symbol { + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + } + menu.addItem(item) + } + } + return menu + } + + /// Opens the menu just above the dock, at the pointer's position along + /// it. An accessibility action has no click to anchor to, and the dock is + /// where its tiles are. + static func popUp(_ entries: [DockMenuEntry], over panelFrame: NSRect?) { + let menu = makeMenu(entries) + var point = NSEvent.mouseLocation + if let frame = panelFrame, !frame.contains(point) { + point = NSPoint(x: frame.midX, y: frame.maxY) + } + menu.popUp(positioning: nil, at: point, in: nil) + } +} + +extension View { + /// A right-click menu and the same menu for `AXShowMenu`. + func dockMenu(_ entries: @escaping () -> [DockMenuEntry]) -> some View { + contextMenu { DockMenuContent(entries: entries()) } + .accessibilityAction(.showMenu) { + // After the action returns: the menu's tracking loop would + // otherwise hold the accessibility call open until it closes, + // and the caller would read that as a failure. + let built = entries() + DispatchQueue.main.async { + DockMenuPresenter.popUp(built, over: DockPanelController.currentFrame) + } + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index 4912782..c7b4d71 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -102,6 +102,10 @@ final class DockPanelController { var onOpenSettings: (() -> Void)? + /// The dock panel's frame while it is on screen, for menus opened by an + /// accessibility action rather than a click. One dock per app. + private(set) static var currentFrame: NSRect? + init(store: DockStore, runtime: WidgetRuntime) { self.store = store self.runtime = runtime @@ -253,6 +257,7 @@ final class DockPanelController { panel.setFrame(frame, display: true) completion?() } + Self.currentFrame = isRevealed ? frame : nil } // MARK: Auto-hide diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift index 2754fa1..4205fc5 100644 --- a/Sources/MenubucketApp/Dock/DockView.swift +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -110,7 +110,7 @@ struct DockView: View { .padding(isClassic ? max(5, tileSize * 0.12) : max(8, tileSize * 0.18)) .background { barBackground } .contentShape(Rectangle()) - .contextMenu { barMenu } + .dockMenu { barMenu } .onDrop(of: DockDrop.acceptedTypes, isTargeted: nil) { providers in DockDrop.receive(providers, store: store, before: nil) } @@ -195,7 +195,7 @@ struct DockView: View { action: { DockActions.openApp(path: app.path) }, dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) } ) - .contextMenu { runningAppMenu(app) } + .dockMenu { runningAppMenu(app) } case .divider: divider case .trash: @@ -205,11 +205,7 @@ struct DockView: View { action: DockActions.openTrash, dropFiles: DockActions.moveToTrash ) - .contextMenu { - Button("Open") { DockActions.openTrash() } - Divider() - settingsButton - } + .dockMenu { trashMenu } } } @@ -262,7 +258,7 @@ struct DockView: View { ) } } - .contextMenu { itemMenu(item) } + .dockMenu { itemMenu(item) } .onDrag { NSItemProvider(object: "\(Self.dragPrefix)\(item.id)" as NSString) } @@ -447,97 +443,88 @@ struct DockView: View { // MARK: Menus - @ViewBuilder - private func itemMenu(_ item: DockItem) -> some View { + private func itemMenu(_ item: DockItem) -> [DockMenuEntry] { + var entries: [DockMenuEntry] = [] switch item.kind { case .app(let path): - Button("Open") { DockActions.open(item) } + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) if let app = running.runningApplication(path: path) { - Button("Hide") { app.hide() } - Button("Quit") { app.terminate() } + entries.append(.action(String(localized: "Hide")) { app.hide() }) + entries.append(.action(String(localized: "Quit")) { app.terminate() }) } - Button("Show in Finder") { DockActions.revealInFinder(path: path) } + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }) case .folder(let path, let color, let label): - Button("Open") { DockActions.open(item) } - Button("Show in Finder") { DockActions.revealInFinder(path: path) } - Menu("Color") { - Button("None") { - store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: nil, label: label))) - } - ForEach(DockItem.FolderColor.allCases, id: \.self) { option in - Button(FolderBadge.name(option)) { - store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: option, label: label))) - } - .disabled(option == color) - } + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }) + let recolor: (DockItem.FolderColor?) -> Void = { option in + store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: option, label: label))) } + entries.append(.submenu( + String(localized: "Color"), + [.action(String(localized: "None"), checked: color == nil) { recolor(nil) }] + + DockItem.FolderColor.allCases.map { option in + .action(FolderBadge.name(option), checked: option == color) { recolor(option) } + } + )) case .file(let path): - Button("Open") { DockActions.open(item) } - Button("Show in Finder") { DockActions.revealInFinder(path: path) } + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }) case .link(let url, _): - Button("Open") { DockActions.open(item) } - Button("Copy Link") { + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Copy Link")) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(url, forType: .string) - } + }) case .shortcut: - Button("Run Shortcut") { DockActions.open(item) } - Button("Open Shortcuts") { + entries.append(.action(String(localized: "Run Shortcut")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Open Shortcuts")) { if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.shortcuts") { DockActions.openApp(path: url.path) } - } + }) case .widget(let id): - Button("Show in BarShelf") { DockActions.open(item) } - Button("Refresh") { runtime.refresh(widgetID: id) } + entries.append(.action(String(localized: "Show in BarShelf")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Refresh")) { runtime.refresh(widgetID: id) }) case .spacer, .separator: - EmptyView() + break } - Divider() - Button("Remove from Dock", role: .destructive) { store.removeItem(item.id) } - Divider() - profilesMenu - settingsButton + entries.append(.divider) + entries.append(.action(String(localized: "Remove from Dock"), destructive: true) { store.removeItem(item.id) }) + entries.append(.divider) + return DockMenuEntry.tidy(entries + barMenu) } - @ViewBuilder - private func runningAppMenu(_ app: RunningApps.App) -> some View { - Button("Keep in Dock") { - store.addItems([DockItem(kind: .app(path: app.path))]) - } + private func runningAppMenu(_ app: RunningApps.App) -> [DockMenuEntry] { + var entries: [DockMenuEntry] = [ + .action(String(localized: "Keep in Dock")) { store.addItems([DockItem(kind: .app(path: app.path))]) }, + ] if let running = NSRunningApplication(processIdentifier: app.processID) { - Button("Hide") { running.hide() } - Button("Quit") { running.terminate() } + entries.append(.action(String(localized: "Hide")) { running.hide() }) + entries.append(.action(String(localized: "Quit")) { running.terminate() }) } - Button("Show in Finder") { DockActions.revealInFinder(path: app.path) } - Divider() - settingsButton + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: app.path) }) + return DockMenuEntry.tidy(entries + [.divider] + barMenu) } - @ViewBuilder - private var barMenu: some View { - profilesMenu - settingsButton + private var trashMenu: [DockMenuEntry] { + DockMenuEntry.tidy([.action(String(localized: "Open")) { DockActions.openTrash() }, .divider] + barMenu) } - @ViewBuilder - private var profilesMenu: some View { + /// Profiles (when there is more than one) and the dock's settings. + private var barMenu: [DockMenuEntry] { + var entries: [DockMenuEntry] = [] if config.profiles.count > 1 { - Menu("Profile") { - ForEach(config.profiles) { profile in - Toggle(isOn: Binding( - get: { profile.id == config.activeProfileID }, - set: { _ in store.activate(profileID: profile.id) } - )) { - Label(profile.name, systemImage: profile.symbol) + entries.append(.submenu( + String(localized: "Profile"), + config.profiles.map { profile in + .action(profile.name, symbol: profile.symbol, checked: profile.id == config.activeProfileID) { + store.activate(profileID: profile.id) } } - } + )) } - } - - private var settingsButton: some View { - Button("Dock Settings…") { onOpenSettings() } + entries.append(.action(String(localized: "Dock Settings…")) { onOpenSettings() }) + return entries } static func showFolderMenu(path: String) { diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift index 29d75c3..cfd6c01 100644 --- a/Tests/MenubucketAppTests/DockStoreTests.swift +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -171,3 +171,32 @@ final class DockStoreTests: XCTestCase { XCTAssertEqual(value, "Deep Work & Co") } } + +/// Dock menus are defined once and drawn as a context menu and as an NSMenu +/// for `AXShowMenu`; the two must carry the same rows. +final class DockMenuTests: XCTestCase { + func testTidyDropsStrayDividers() { + let entries = DockMenuEntry.tidy([ + .divider, .action("A") {}, .divider, .divider, .action("B") {}, .divider, + ]) + XCTAssertEqual(entries.map(\.title), ["A", "", "B"]) + } + + func testNSMenuMatchesTheEntries() { + var ran: [String] = [] + let menu = DockMenuPresenter.makeMenu([ + .action("Open") { ran.append("open") }, + .divider, + .submenu("Profile", [.action("Work", checked: true) { ran.append("work") }]), + .action("Remove from Dock", destructive: true) { ran.append("remove") }, + ]) + XCTAssertEqual(menu.items.map(\.title), ["Open", "", "Profile", "Remove from Dock"]) + XCTAssertTrue(menu.items[1].isSeparatorItem) + guard let work = menu.items[2].submenu?.items.first else { return XCTFail("no submenu") } + XCTAssertEqual(work.state, .on) + for item in [menu.items[0], work, menu.items[3]] { + XCTAssertTrue(NSApplication.shared.sendAction(item.action!, to: item.target, from: item)) + } + XCTAssertEqual(ran, ["open", "work", "remove"]) + } +} From 056e6cfac1968dec3a5bf77913d902f48111898e Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 16:15:08 +0900 Subject: [PATCH 03/10] fix(dock): look like the Apple Dock Measured against the Apple Dock on macOS 27, the Classic dock was a grey slab: SwiftUI's glass follows the window's active state and this panel is never active, so it drew flat. It now hosts AppKit's clear NSGlassEffectView, with the Apple Dock's proportions (padding ~0.19 icon, corners ~0.2 of the bar, 3 pt running dots in the padding). Widgets no longer stretch the bar: in Classic they keep to icon height and show their name and main reading or two bars (the R14-B summary). Full cards stay in the Shelf style. A new dock starts at the Apple Dock's icon size, and settings offer to match it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Localization/Localizable.xcstrings | 42 ++++++++ .../MenubucketApp/Dock/DockSettingsPage.swift | 22 +++-- Sources/MenubucketApp/Dock/DockStore.swift | 8 +- Sources/MenubucketApp/Dock/DockView.swift | 48 +++++++-- .../MenubucketApp/Dock/DockWidgetGlance.swift | 97 +++++++++++++++++++ Sources/MenubucketCore/AppleDock.swift | 6 ++ docs/DOCK.md | 8 +- 7 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 Sources/MenubucketApp/Dock/DockWidgetGlance.swift diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index e08cfe7..df8b394 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -575,7 +575,18 @@ } } }, + "A sturdier bar with names under icons, and widgets as full cards.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 아래에 이름이 붙는 단단한 막대. 위젯은 전체 카드로 보입니다." + } + } + } + }, "A sturdier bar with names under icons; roomy for widgets.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -3050,6 +3061,16 @@ } } }, + "Error": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류" + } + } + } + }, "Every %lf s": { "comment": "Refresh interval in seconds", "localizations": { @@ -4678,6 +4699,7 @@ } }, "Like the Apple Dock: icons on glass, names on hover.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -4687,6 +4709,16 @@ } } }, + "Like the Apple Dock: icons on glass, names on hover. Widgets show their main reading at icon height.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock처럼: 유리 위의 아이콘, 가리키면 이름 표시. 위젯은 아이콘 높이에 주요 값만 보여 줍니다." + } + } + } + }, "Limit number of rows": { "localizations": { "ko": { @@ -4899,6 +4931,16 @@ } } }, + "Match Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock에 맞추기" + } + } + } + }, "Max": { "localizations": { "ko": { diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index 2e60ad3..12c671c 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -124,8 +124,8 @@ struct DockSettingsPage: View { } .pickerStyle(.segmented) Text(config.style == .classic - ? "Like the Apple Dock: icons on glass, names on hover." - : "A sturdier bar with names under icons; roomy for widgets.") + ? "Like the Apple Dock: icons on glass, names on hover. Widgets show their main reading at icon height." + : "A sturdier bar with names under icons, and widgets as full cards.") .font(.caption) .foregroundStyle(.secondary) Picker("Position on screen", selection: binding(\.edge)) { @@ -135,12 +135,20 @@ struct DockSettingsPage: View { } .pickerStyle(.segmented) LabeledContent("Icon size") { - Slider(value: binding(\.tileSize), in: DockConfiguration.tileSizeRange, step: 2) - .frame(maxWidth: 220) + HStack { + Slider(value: binding(\.tileSize), in: DockConfiguration.tileSizeRange, step: 2) + .frame(maxWidth: 220) + if let apple = store.appleDock.tileSize, abs(apple - config.tileSize) >= 1 { + Button("Match Apple Dock") { store.update { $0.tileSize = apple } } + .controlSize(.small) + } + } } - LabeledContent("Widget size") { - Slider(value: binding(\.widgetSize), in: DockConfiguration.widgetSizeRange, step: 2) - .frame(maxWidth: 220) + if config.style == .shelf { + LabeledContent("Widget size") { + Slider(value: binding(\.widgetSize), in: DockConfiguration.widgetSizeRange, step: 2) + .frame(maxWidth: 220) + } } Toggle("Magnification", isOn: binding(\.magnification)) .disabled(config.style != .classic) diff --git a/Sources/MenubucketApp/Dock/DockStore.swift b/Sources/MenubucketApp/Dock/DockStore.swift index ef0a61b..0565da9 100644 --- a/Sources/MenubucketApp/Dock/DockStore.swift +++ b/Sources/MenubucketApp/Dock/DockStore.swift @@ -34,7 +34,13 @@ final class DockStore: ObservableObject { ) { self.fileURL = fileURL self.appleDock = appleDock - configuration = DockConfiguration.load(from: fileURL) + var configuration = DockConfiguration.load(from: fileURL) + // A first dock starts at the Apple Dock's own icon size. + if !FileManager.default.fileExists(atPath: fileURL.path), let size = appleDock.tileSize { + configuration.tileSize = size + configuration.normalize() + } + self.configuration = configuration } var activeProfile: DockProfile { configuration.activeProfile } diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift index 4205fc5..70a49de 100644 --- a/Sources/MenubucketApp/Dock/DockView.swift +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -107,7 +107,7 @@ struct DockView: View { } if tiles.isEmpty { emptyHint } } - .padding(isClassic ? max(5, tileSize * 0.12) : max(8, tileSize * 0.18)) + .padding(barPadding) .background { barBackground } .contentShape(Rectangle()) .dockMenu { barMenu } @@ -142,11 +142,19 @@ struct DockView: View { } } + /// Measured off the Apple Dock (macOS 26–27): about a fifth of an icon + /// around the icons, and corners a fifth of the bar's thickness. + private var barPadding: CGFloat { isClassic ? max(4, tileSize * 0.19) : max(8, tileSize * 0.18) } + private var barCornerRadius: CGFloat { isClassic ? (tileSize + barPadding * 2) * 0.2 : 18 } + @ViewBuilder private var barBackground: some View { - let shape = RoundedRectangle(cornerRadius: isClassic ? tileSize * 0.36 : 18, style: .continuous) + let shape = RoundedRectangle(cornerRadius: barCornerRadius, style: .continuous) if #available(macOS 26.0, *), isClassic { - shape.fill(.clear).glassEffect(.regular, in: shape) + // AppKit's clear glass, the see-through Liquid Glass the Apple + // Dock shows. Regular glass and the window materials came out as + // a flat light-grey slab in this never-active panel. + DockGlass(cornerRadius: barCornerRadius) } else { shape .fill(isClassic ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(.regularMaterial)) @@ -341,8 +349,8 @@ struct DockView: View { .overlay(alignment: runningDotAlignment) { if isRunning { Circle() - .fill(Color.primary.opacity(0.75)) - .frame(width: 4, height: 4) + .fill(Color.primary.opacity(0.7)) + .frame(width: isClassic ? 3 : 4, height: isClassic ? 3 : 4) .offset(runningDotOffset) } } @@ -374,7 +382,8 @@ struct DockView: View { } private var runningDotOffset: CGSize { - let gap = isClassic ? max(4, tileSize * 0.1) : 2 + // In the middle of the bar's padding, as on the Apple Dock. + let gap = isClassic ? barPadding * 0.55 + 1.5 : 2 switch edge { case .bottom: return CGSize(width: 0, height: isClassic ? gap : gap + 2) case .left: return CGSize(width: -gap, height: 0) @@ -425,7 +434,14 @@ struct DockView: View { private func widgetTile(item: DockItem, widgetID: String) -> some View { let frame = widgetFrame(for: widgetID) let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) - if let widget = runtime.widgets.first(where: { $0.id == widgetID }) { + if isClassic, let widget = runtime.widgets.first(where: { $0.id == widgetID }) { + // Classic keeps the Apple Dock's height: a glance, not a card. + DockWidgetGlance(widget: widget, runtime: runtime, height: tileSize) + .frame(width: tileSize * DockWidgetGlance.widthFactor(size: runtime.effectiveSize(for: widgetID))) + .contentShape(Rectangle()) + .onTapGesture { DockActions.open(item) } + .help(widget.displayName) + } else if let widget = runtime.widgets.first(where: { $0.id == widgetID }) { WidgetCardView(widget: widget, runtime: runtime, compactHeight: frame.height, placement: .single) .frame(width: frame.width, height: frame.height) .clipShape(shape) @@ -728,3 +744,21 @@ enum DockDrop { return true } } + +/// The system's Liquid Glass as an AppKit view, for the Classic bar. +@available(macOS 26.0, *) +private struct DockGlass: NSViewRepresentable { + let cornerRadius: CGFloat + + func makeNSView(context: Context) -> NSGlassEffectView { + let view = NSGlassEffectView() + view.style = .clear + view.cornerRadius = cornerRadius + return view + } + + func updateNSView(_ view: NSGlassEffectView, context: Context) { + view.cornerRadius = cornerRadius + } +} + diff --git a/Sources/MenubucketApp/Dock/DockWidgetGlance.swift b/Sources/MenubucketApp/Dock/DockWidgetGlance.swift new file mode 100644 index 0000000..d835f4b --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockWidgetGlance.swift @@ -0,0 +1,97 @@ +import MenubucketCore +import SwiftUI + +/// A widget at icon height, for the Classic dock: its name, its main value, +/// and up to two bars, the way the desktop widgets boil a view down (R14-B +/// `SharedShelf.summarize`). The full card lives in the Shelf style and the +/// popup; squeezing it to icon height would only crop it. +struct DockWidgetGlance: View { + let widget: LoadedWidget + let height: CGFloat + @ObservedObject private var model: WidgetCardModel + + init(widget: LoadedWidget, runtime: WidgetRuntime, height: CGFloat) { + self.widget = widget + self.height = height + _model = ObservedObject(wrappedValue: runtime.cardModel(for: widget.id)) + } + + /// How wide a glance is for the widget's chosen size, in icon heights. + static func widthFactor(size: String) -> CGFloat { + switch size { + case "XS", "S": return 2.2 + case "L": return 3.6 + default: return 2.8 + } + } + + private var summary: SharedShelf.Summary? { + model.snapshot.viewTree.map { SharedShelf.summarize($0, fallbackTitle: widget.displayName) } + } + + var body: some View { + let summary = self.summary + let metrics = Array((summary?.metrics ?? []).prefix(2)) + VStack(alignment: .leading, spacing: height * 0.06) { + HStack(spacing: 4) { + if let symbol = summary?.symbol { + Image(systemName: symbol).imageScale(.small) + } + // The widget's own name: a summary's first text can be a + // reading (System's load average came out as its "title"). + Text(widget.displayName) + .lineLimit(1) + } + .font(.system(size: max(8, height * 0.2), weight: .medium)) + .foregroundStyle(.secondary) + + if model.snapshot.error != nil { + Label("Error", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: max(9, height * 0.26), weight: .semibold)) + .foregroundStyle(.orange) + } else if !metrics.isEmpty { + ForEach(Array(metrics.enumerated()), id: \.offset) { _, metric in + meter(metric) + } + } else { + Text(summary?.value ?? "—") + .font(.system(size: max(10, height * 0.36), weight: .semibold, design: .rounded)) + .foregroundStyle(nodeColor(summary?.tone) ?? .primary) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + } + .padding(.horizontal, height * 0.16) + .frame(height: height, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private func meter(_ metric: SharedShelf.Metric) -> some View { + let barHeight = max(3, height * 0.08) + return HStack(spacing: 4) { + if let label = metric.label { + Text(label) + .font(.system(size: max(8, height * 0.18))) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.12)) + Capsule() + .fill(nodeColor(metric.tone) ?? .accentColor) + .frame(width: proxy.size.width * CGFloat(min(max(metric.fraction ?? 0, 0), 1))) + } + } + .frame(height: barHeight) + if let value = metric.value { + Text(value) + .font(.system(size: max(8, height * 0.2), weight: .semibold).monospacedDigit()) + .lineLimit(1) + .fixedSize() + } + } + } +} diff --git a/Sources/MenubucketCore/AppleDock.swift b/Sources/MenubucketCore/AppleDock.swift index c6486b9..b69e2d8 100644 --- a/Sources/MenubucketCore/AppleDock.swift +++ b/Sources/MenubucketCore/AppleDock.swift @@ -284,6 +284,12 @@ public final class AppleDock { restart() } + /// The Apple Dock's icon size in points, when it has one set. + public var tileSize: Double? { + defaults.synchronize() + return Self.double(defaults.value(forKey: "tilesize")) + } + /// "bottom", "left", or "right". public var orientation: String { defaults.synchronize() diff --git a/docs/DOCK.md b/docs/DOCK.md index 4455461..f5a3662 100644 --- a/docs/DOCK.md +++ b/docs/DOCK.md @@ -45,9 +45,11 @@ Drag apps, folders, files, or web links onto the dock to add them. Drag an item onto another to move it. Right-click any item for its menu, including **Remove from Dock**. -**Classic** looks like the Apple Dock: icons on glass (Liquid Glass on macOS -26 and later), names on hover, and optional magnification. **Shelf** is a -sturdier bar with names under icons, with more room for widgets. +**Classic** looks like the Apple Dock: its icon size by default, icons on +clear Liquid Glass (macOS 26 and later), names on hover, optional +magnification. Widgets keep to icon height there, showing their name and main +reading (or two small bars). **Shelf** is a sturdier bar with names under +icons and widgets as full cards. ## Profiles From eb27e567039c4f05028c66770bc06957a5b5044f Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 16:26:22 +0900 Subject: [PATCH 04/10] fix(dock): menus by right-click and named accessibility actions Driving the dock through accessibility showed AXShowMenu never opened a tile's own menu: SwiftUI's context menu claims the action and does nothing in this never-active panel, and an accessibilityAction(.showMenu) of our own was never called. Menus no longer go through SwiftUI: - Right-click and control-click are handled by the dock's hosting view, which opens the menu of the tile under the pointer, else the bar's. - Each tile offers the same commands as named accessibility actions ("Remove from Dock", "Profile: Work"), how VoiceOver presents a SwiftUI view's commands. Tiles stop advertising a ShowMenu that did nothing. Keeps the nlc flows used to test this under .context/nlc. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/MenubucketApp/Dock/DockMenus.swift | 124 +++++++++--------- .../Dock/DockPanelController.swift | 22 +++- Sources/MenubucketApp/Dock/DockView.swift | 45 +++++-- Tests/MenubucketAppTests/DockStoreTests.swift | 18 ++- 4 files changed, 133 insertions(+), 76 deletions(-) diff --git a/Sources/MenubucketApp/Dock/DockMenus.swift b/Sources/MenubucketApp/Dock/DockMenus.swift index ff1001c..032735f 100644 --- a/Sources/MenubucketApp/Dock/DockMenus.swift +++ b/Sources/MenubucketApp/Dock/DockMenus.swift @@ -2,10 +2,11 @@ import AppKit import SwiftUI /// One row of a dock tile's menu. A tile's menu is defined once, as these, -/// and drawn two ways: as the SwiftUI context menu a right-click opens, and -/// as an `NSMenu` for `AXShowMenu`. SwiftUI's context menu does not answer -/// that accessibility action in the dock's non-activating panel, which left -/// VoiceOver (VO-Shift-M) and automation with no way to reach these commands. +/// and offered two ways: as an `NSMenu` on right-click, and as named +/// accessibility actions. SwiftUI's context menu could not be opened through +/// accessibility in the dock's non-activating panel (AXShowMenu reached +/// neither it nor an action of our own), which left VoiceOver with no way to +/// reach these commands. struct DockMenuEntry: Identifiable { enum Kind { case action(() -> Void) @@ -37,6 +38,28 @@ struct DockMenuEntry: Identifiable { static var divider: DockMenuEntry { DockMenuEntry(title: "", kind: .divider) } + /// Every enabled command, submenus spelled out ("Profile: Work"), for + /// the accessibility actions list. + struct Flat: Identifiable { + let id = UUID() + let title: String + let run: () -> Void + } + + static func flattened(_ entries: [DockMenuEntry], prefix: String? = nil) -> [Flat] { + entries.flatMap { entry -> [Flat] in + switch entry.kind { + case .divider: + return [] + case .submenu(let children): + return flattened(children, prefix: entry.title) + case .action(let run): + guard entry.isEnabled else { return [] } + return [Flat(title: prefix.map { "\($0): \(entry.title)" } ?? entry.title, run: run)] + } + } + } + /// Leading, trailing, and doubled dividers dropped, so optional groups can /// be appended without bookkeeping. static func tidy(_ entries: [DockMenuEntry]) -> [DockMenuEntry] { @@ -53,40 +76,7 @@ struct DockMenuEntry: Identifiable { } } -/// The SwiftUI rendering, for `.contextMenu`. -struct DockMenuContent: View { - let entries: [DockMenuEntry] - - var body: some View { - ForEach(entries) { entry in - switch entry.kind { - case .divider: - Divider() - case .submenu(let children): - Menu(entry.title) { DockMenuContent(entries: children) } - case .action(let run): - if entry.isChecked { - Toggle(isOn: Binding(get: { true }, set: { _ in run() })) { label(entry) } - .disabled(!entry.isEnabled) - } else { - Button(role: entry.isDestructive ? .destructive : nil, action: run) { label(entry) } - .disabled(!entry.isEnabled) - } - } - } - } - - @ViewBuilder - private func label(_ entry: DockMenuEntry) -> some View { - if let symbol = entry.symbol { - Label(entry.title, systemImage: symbol) - } else { - Text(entry.title) - } - } -} - -/// The AppKit rendering, popped up for `AXShowMenu`. +/// The menu itself, for a right-click. enum DockMenuPresenter { /// Keeps the closures alive while the menu is open. private final class Target: NSObject { @@ -121,32 +111,48 @@ enum DockMenuPresenter { } return menu } +} - /// Opens the menu just above the dock, at the pointer's position along - /// it. An accessibility action has no click to anchor to, and the dock is - /// where its tiles are. - static func popUp(_ entries: [DockMenuEntry], over panelFrame: NSRect?) { - let menu = makeMenu(entries) - var point = NSEvent.mouseLocation - if let frame = panelFrame, !frame.contains(point) { - point = NSPoint(x: frame.midX, y: frame.maxY) - } - menu.popUp(positioning: nil, at: point, in: nil) +/// Which menu a right-click opens: the tile under the pointer, else the +/// bar's. The dock opens its menus itself (`DockHostingView`) rather than +/// through SwiftUI's context menu. +final class DockMenuRouter { + static let shared = DockMenuRouter() + + private var hovered: (id: String, entries: () -> [DockMenuEntry])? + var bar: (() -> [DockMenuEntry])? + + func enter(_ id: String, entries: @escaping () -> [DockMenuEntry]) { + hovered = (id, entries) + } + + func exit(_ id: String) { + if hovered?.id == id { hovered = nil } + } + + /// The entries for a right-click right now. + var current: [DockMenuEntry]? { + if let hovered { return hovered.entries() } + return bar?() } } extension View { - /// A right-click menu and the same menu for `AXShowMenu`. - func dockMenu(_ entries: @escaping () -> [DockMenuEntry]) -> some View { - contextMenu { DockMenuContent(entries: entries()) } - .accessibilityAction(.showMenu) { - // After the action returns: the menu's tracking loop would - // otherwise hold the accessibility call open until it closes, - // and the caller would read that as a failure. - let built = entries() - DispatchQueue.main.async { - DockMenuPresenter.popUp(built, over: DockPanelController.currentFrame) - } + /// A tile's menu: on right-click (through `DockMenuRouter`), and as named + /// accessibility actions, the way VoiceOver offers a SwiftUI view's + /// commands (VO-Command-Space). + func dockMenu(id: String, _ entries: @escaping () -> [DockMenuEntry]) -> some View { + accessibilityActions { + ForEach(DockMenuEntry.flattened(entries())) { entry in + Button(entry.title) { entry.run() } + } + } + .onHover { inside in + if inside { + DockMenuRouter.shared.enter(id, entries: entries) + } else { + DockMenuRouter.shared.exit(id) } + } } } diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index c7b4d71..f2a8f06 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -43,6 +43,24 @@ final class DockHostingView: NSHostingView { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func rightMouseDown(with event: NSEvent) { + showMenu(for: event) + } + + override func mouseDown(with event: NSEvent) { + // Control-click is a right-click, as everywhere on the Mac. + if event.modifierFlags.contains(.control) { + showMenu(for: event) + } else { + super.mouseDown(with: event) + } + } + + private func showMenu(for event: NSEvent) { + guard let entries = DockMenuRouter.shared.current, !entries.isEmpty else { return } + NSMenu.popUpContextMenu(DockMenuPresenter.makeMenu(entries), with: event, for: self) + } + override func invalidateIntrinsicContentSize() { super.invalidateIntrinsicContentSize() onIdealSizeChange?() @@ -102,9 +120,6 @@ final class DockPanelController { var onOpenSettings: (() -> Void)? - /// The dock panel's frame while it is on screen, for menus opened by an - /// accessibility action rather than a click. One dock per app. - private(set) static var currentFrame: NSRect? init(store: DockStore, runtime: WidgetRuntime) { self.store = store @@ -257,7 +272,6 @@ final class DockPanelController { panel.setFrame(frame, display: true) completion?() } - Self.currentFrame = isRevealed ? frame : nil } // MARK: Auto-hide diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift index 70a49de..35520c4 100644 --- a/Sources/MenubucketApp/Dock/DockView.swift +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -110,7 +110,7 @@ struct DockView: View { .padding(barPadding) .background { barBackground } .contentShape(Rectangle()) - .dockMenu { barMenu } + .onAppear { DockMenuRouter.shared.bar = { barMenu } } .onDrop(of: DockDrop.acceptedTypes, isTargeted: nil) { providers in DockDrop.receive(providers, store: store, before: nil) } @@ -201,9 +201,9 @@ struct DockView: View { image: NSWorkspace.shared.icon(forFile: app.path), isRunning: true, scale: scale, action: { DockActions.openApp(path: app.path) }, - dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) } + dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) }, + menu: { runningAppMenu(app) } ) - .dockMenu { runningAppMenu(app) } case .divider: divider case .trash: @@ -211,9 +211,9 @@ struct DockView: View { id: tile.id, title: String(localized: "Trash"), image: NSImage(named: NSImage.trashEmptyName), isRunning: false, scale: scale, action: DockActions.openTrash, - dropFiles: DockActions.moveToTrash + dropFiles: DockActions.moveToTrash, + menu: { trashMenu } ) - .dockMenu { trashMenu } } } @@ -238,7 +238,8 @@ struct DockView: View { image: DockActions.icon(for: item), isRunning: running.isRunning(path: path), scale: scale, action: { DockActions.open(item) }, - dropFiles: { urls in DockActions.open(urls, withAppAt: path) } + dropFiles: { urls in DockActions.open(urls, withAppAt: path) }, + menu: { itemMenu(item) } ) case .folder(let path, let color, let label): iconTile( @@ -249,24 +250,27 @@ struct DockView: View { dropFiles: nil, custom: color.map { color in AnyView(FolderBadge(color: color, label: label ?? DockActions.displayName(for: item))) - } + }, + menu: { itemMenu(item) } ) case .link(let url, _): iconTile( id: item.id, title: DockActions.displayName(for: item), image: nil, isRunning: false, scale: scale, action: { DockActions.open(item) }, dropFiles: nil, - custom: AnyView(LinkBadge(url: url)) + custom: AnyView(LinkBadge(url: url)), + menu: { itemMenu(item) } ) case .file, .shortcut: iconTile( id: item.id, title: DockActions.displayName(for: item), image: DockActions.icon(for: item), isRunning: false, scale: scale, - action: { DockActions.open(item) }, dropFiles: nil + action: { DockActions.open(item) }, dropFiles: nil, + menu: { itemMenu(item) } ) } } - .dockMenu { itemMenu(item) } + .modifier(OuterItemMenu(item: item, menu: { itemMenu(item) })) .onDrag { NSItemProvider(object: "\(Self.dragPrefix)\(item.id)" as NSString) } @@ -310,7 +314,8 @@ struct DockView: View { scale: CGFloat, action: @escaping () -> Void, dropFiles: (([URL]) -> Void)?, - custom: AnyView? = nil + custom: AnyView? = nil, + menu: @escaping () -> [DockMenuEntry] ) -> some View { let grown = tileSize * scale let anchor: UnitPoint = switch edge { @@ -357,6 +362,8 @@ struct DockView: View { .contentShape(Rectangle()) } .buttonStyle(DockPressStyle()) + // On the button itself: it is the accessibility element. + .dockMenu(id: id, menu) .overlay(alignment: labelAlignment) { if isClassic, hoveredID == id { HoverLabel(title: title) @@ -762,3 +769,19 @@ private struct DockGlass: NSViewRepresentable { } } + +/// Widgets, spaces, and dividers have no button of their own, so their menu +/// hangs on the item; icon tiles carry theirs on the button. +private struct OuterItemMenu: ViewModifier { + let item: DockItem + let menu: () -> [DockMenuEntry] + + func body(content: Content) -> some View { + switch item.kind { + case .widget, .spacer, .separator: + content.dockMenu(id: item.id, menu) + default: + content + } + } +} diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift index cfd6c01..cf5f6c8 100644 --- a/Tests/MenubucketAppTests/DockStoreTests.swift +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -172,8 +172,8 @@ final class DockStoreTests: XCTestCase { } } -/// Dock menus are defined once and drawn as a context menu and as an NSMenu -/// for `AXShowMenu`; the two must carry the same rows. +/// Dock menus are defined once and offered as a right-click NSMenu and as +/// named accessibility actions; both must carry the same commands. final class DockMenuTests: XCTestCase { func testTidyDropsStrayDividers() { let entries = DockMenuEntry.tidy([ @@ -182,6 +182,20 @@ final class DockMenuTests: XCTestCase { XCTAssertEqual(entries.map(\.title), ["A", "", "B"]) } + /// VoiceOver gets every command as a named action, submenus spelled out. + func testFlattenedActionsSpellOutSubmenus() { + var ran: [String] = [] + let flat = DockMenuEntry.flattened([ + .action("Open") { ran.append("open") }, + .divider, + .action("Hidden", enabled: false) {}, + .submenu("Profile", [.action("Work") { ran.append("work") }]), + ]) + XCTAssertEqual(flat.map(\.title), ["Open", "Profile: Work"]) + flat.forEach { $0.run() } + XCTAssertEqual(ran, ["open", "work"]) + } + func testNSMenuMatchesTheEntries() { var ran: [String] = [] let menu = DockMenuPresenter.makeMenu([ From 42c7899dbb18dfdde8bdef45c1c25966125704c7 Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 16:51:44 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(dock):=20the=20Apple=20Dock's=20sett?= =?UTF-8?q?ings=20=E2=80=94=20magnification=20amount,=20display,=20indicat?= =?UTF-8?q?ors,=20opening=20bounce,=20auto-hide=20delay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Size and magnification sliders read Small–Large, without 32 ticks. - Magnification amount (how big a hovered icon grows). - Show on: the main display, or the display whose dock edge the pointer rests at, as the Apple Dock moves between displays. - Auto-hide waits for the pointer to rest at the edge (Show after), so crossing onto a display below does not pull the dock out. - Animate opening apps: an icon bounces until its app has launched. - Show indicators for open apps can be turned off. - The 'same edge as the Apple Dock' warning only with one display; with several the Apple Dock moves and the warning was wrong. Co-Authored-By: Claude Opus 5.5 (1M context) --- Localization/Localizable.xcstrings | 91 +++++++++++++++++++ Sources/MenubucketApp/Dock/DockActions.swift | 31 ++++++- .../Dock/DockPanelController.swift | 86 +++++++++++++++--- .../MenubucketApp/Dock/DockSettingsPage.swift | 66 ++++++++++++-- Sources/MenubucketApp/Dock/DockView.swift | 38 +++++++- Sources/MenubucketCore/Dock.swift | 34 +++++++ .../DockConfigurationTests.swift | 22 +++++ docs/DOCK.md | 5 + 8 files changed, 350 insertions(+), 23 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index df8b394..9508ce5 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -136,6 +136,16 @@ } } }, + "%@ s": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@초" + } + } + } + }, "%@ stepper": { "localizations": { "ko": { @@ -968,6 +978,16 @@ } } }, + "Animate opening apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱을 열 때 애니메이션 표시" + } + } + } + }, "App": { "comment": "Menu bar icon choice", "localizations": { @@ -2632,6 +2652,16 @@ } } }, + "Display with the pointer": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터가 있는 디스플레이" + } + } + } + }, "Displays": { "localizations": { "ko": { @@ -3971,6 +4001,7 @@ } }, "Icon size": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -4000,6 +4031,16 @@ } } }, + "Immediately": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "즉시" + } + } + } + }, "Import Hammerspoon Settings": { "localizations": { "ko": { @@ -4901,6 +4942,26 @@ } } }, + "Magnification amount": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "확대 정도" + } + } + } + }, + "Main display": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "주 디스플레이" + } + } + } + }, "Make this card full width": { "localizations": { "ko": { @@ -7911,6 +7972,16 @@ } } }, + "Show after": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시까지 대기" + } + } + } + }, "Show an icon": { "localizations": { "ko": { @@ -7991,6 +8062,16 @@ } } }, + "Show indicators for open apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "열린 앱에 표시기 보기" + } + } + } + }, "Show information from another app, command-line tool, or web service. Some need that tool installed first.": { "localizations": { "ko": { @@ -8011,6 +8092,16 @@ } } }, + "Show on": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시할 위치" + } + } + } + }, "Show on the Shelf": { "localizations": { "ko": { diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift index 9c8db48..5d25a04 100644 --- a/Sources/MenubucketApp/Dock/DockActions.swift +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -6,6 +6,8 @@ import UniformTypeIdentifiers /// Apps with a Dock presence that are running now, in launch order. Feeds the /// running dots and the section of apps that are open but not in the profile. final class RunningApps: ObservableObject { + /// One per app: the dock and the actions that launch from it share it. + static let shared = RunningApps() struct App: Identifiable, Equatable { let path: String let bundleID: String? @@ -15,6 +17,11 @@ final class RunningApps: ObservableObject { @Published private(set) var apps: [App] = [] @Published private(set) var frontmostPath: String? + /// Apps opened from the dock that have not finished launching; their + /// icons bounce until they have, as on the Apple Dock. + @Published private(set) var launching: Set = [] + /// Longest a bounce lasts if an app never reports that it launched. + static let launchTimeout: TimeInterval = 15 private var observers: [NSObjectProtocol] = [] @@ -27,7 +34,12 @@ final class RunningApps: ObservableObject { NSWorkspace.didHideApplicationNotification, NSWorkspace.didUnhideApplicationNotification, ] { - observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in + if name == NSWorkspace.didLaunchApplicationNotification, + let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let url = app.bundleURL { + self?.launching.remove(Self.key(url.path)) + } self?.reload() }) } @@ -51,6 +63,20 @@ final class RunningApps: ObservableObject { if front != frontmostPath { frontmostPath = front } } + /// Starts an icon bouncing, unless the app is already up. + func markLaunching(path: String) { + let key = Self.key(path) + guard !isRunning(path: key) else { return } + launching.insert(key) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.launchTimeout) { [weak self] in + self?.launching.remove(key) + } + } + + func isLaunching(path: String) -> Bool { + launching.contains(Self.key(path)) + } + func isRunning(path: String) -> Bool { let key = Self.key(path) return apps.contains { $0.path == key } @@ -98,6 +124,9 @@ enum DockActions { /// Launches the app, or brings it forward with a reopen event so an app /// with no windows opens one — what clicking the Apple Dock does. static func openApp(path: String) { + if DockStore.shared.configuration.animateOpening { + RunningApps.shared.markLaunching(path: path) + } let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: path), configuration: configuration) diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index f2a8f06..54cbd60 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -101,7 +101,7 @@ final class DockHostingView: NSHostingView { final class DockPanelController { private let store: DockStore private let runtime: WidgetRuntime - private let running = RunningApps() + private let running = RunningApps.shared private let panel = DockPanel() private var hostingView: DockHostingView! private var cancellables: Set = [] @@ -112,6 +112,8 @@ final class DockPanelController { /// Whether the dock is out (not slid away by auto-hide). private var isRevealed = true private var hideWorkItem: DispatchWorkItem? + private var revealWorkItem: DispatchWorkItem? + private var moveWorkItem: DispatchWorkItem? /// The edge band, in points, that brings an auto-hidden dock back. static let revealBand: CGFloat = 2 @@ -222,12 +224,35 @@ final class DockPanelController { private var headroom: (along: CGFloat, across: CGFloat) { let tile = CGFloat(config.tileSize) let magnifies = config.style == .classic && config.magnification - let grow = magnifies ? tile * DockView.maxMagnification : 0 + let grow = tile * DockView.magnification(for: config) // Labels sit past the grown icon; the profile banner past the bar. return (along: magnifies ? tile * 2.5 : tile, across: grow + 48) } - private var screen: NSScreen? { NSScreen.screens.first ?? NSScreen.main } + /// The display the dock is on: the main one, or (following the pointer) + /// the one it last moved to. + private var screen: NSScreen? { + let main = NSScreen.screens.first ?? NSScreen.main + guard config.display == .pointer, let pointerDisplayID else { return main } + return NSScreen.screens.first { Self.displayID(of: $0) == pointerDisplayID } ?? main + } + + private var pointerDisplayID: CGDirectDisplayID? + + private static func displayID(of screen: NSScreen) -> CGDirectDisplayID? { + (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value + } + + /// Whether the pointer rests in the reveal band of the dock's edge of + /// `screen`. + private func isAtEdge(_ point: NSPoint, of screen: NSScreen) -> Bool { + let full = screen.frame + return switch config.edge { + case .bottom: point.y <= full.minY + Self.revealBand && point.x >= full.minX && point.x <= full.maxX + case .left: point.x <= full.minX + Self.revealBand && point.y >= full.minY && point.y <= full.maxY + case .right: point.x >= full.maxX - Self.revealBand - 1 && point.y >= full.minY && point.y <= full.maxY + } + } /// The panel frame for the current size and edge, out on screen or slid /// past the edge (the current state unless `revealed` says otherwise). @@ -296,16 +321,31 @@ final class DockPanelController { } private func mouseMoved() { - guard config.mode.showsDock, config.autoHide, let screen else { return } + guard config.mode.showsDock else { return } let point = NSEvent.mouseLocation - let full = screen.frame - let atEdge: Bool = switch config.edge { - case .bottom: point.y <= full.minY + Self.revealBand && point.x >= full.minX && point.x <= full.maxX - case .left: point.x <= full.minX + Self.revealBand && point.y >= full.minY && point.y <= full.maxY - case .right: point.x >= full.maxX - Self.revealBand - 1 && point.y >= full.minY && point.y <= full.maxY - } + followPointerIfNeeded(point) + guard config.autoHide, let screen else { return } + let atEdge = isAtEdge(point, of: screen) if !isRevealed { - if atEdge { setRevealed(true) } + // Out only after the pointer has rested at the edge a moment, so + // passing over the edge (or onto a display below) does not pull + // the dock out. + if atEdge { + if revealWorkItem == nil { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.revealWorkItem = nil + if let screen = self.screen, self.isAtEdge(NSEvent.mouseLocation, of: screen) { + self.setRevealed(true) + } + } + revealWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + config.autoHideDelay, execute: work) + } + } else { + revealWorkItem?.cancel() + revealWorkItem = nil + } return } let inside = barFrame().insetBy(dx: -12, dy: -12).contains(point) || atEdge @@ -322,6 +362,30 @@ final class DockPanelController { } } + /// Following the pointer: resting at the dock's edge of another display + /// moves the dock there, as the Apple Dock does. + private func followPointerIfNeeded(_ point: NSPoint) { + guard config.display == .pointer, + let target = NSScreen.screens.first(where: { NSMouseInRect(point, $0.frame, false) }), + let targetID = Self.displayID(of: target), + targetID != screen.flatMap(Self.displayID(of:)), + isAtEdge(point, of: target) + else { return } + // One pending move at a time; it re-checks the pointer when it fires. + guard moveWorkItem == nil else { return } + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.moveWorkItem = nil + guard NSMouseInRect(NSEvent.mouseLocation, target.frame, false), + self.isAtEdge(NSEvent.mouseLocation, of: target) else { return } + self.pointerDisplayID = targetID + self.placePanel(animated: false) + if self.config.autoHide { self.setRevealed(true) } + } + moveWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + max(0.3, config.autoHideDelay), execute: work) + } + /// The bar's own frame on screen, without the headroom. private func barFrame() -> NSRect { let frame = panel.frame diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index 12c671c..9d40a48 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -110,8 +110,12 @@ struct DockSettingsPage: View { } } + /// Only said with one display: with several, the Apple Dock moves to + /// whichever the pointer rests at, so "the same edge" may not be. private var sharesEdgeWithAppleDock: Bool { - store.appleDock.orientation == config.edge.rawValue && store.appleDock.visibility.autohide != true + NSScreen.screens.count == 1 + && store.appleDock.orientation == config.edge.rawValue + && store.appleDock.visibility.autohide != true } // MARK: Appearance @@ -134,10 +138,17 @@ struct DockSettingsPage: View { Text("Right").tag(DockConfiguration.Edge.right) } .pickerStyle(.segmented) - LabeledContent("Icon size") { + LabeledContent("Size") { HStack { - Slider(value: binding(\.tileSize), in: DockConfiguration.tileSizeRange, step: 2) - .frame(maxWidth: 220) + Slider(value: binding(\.tileSize), in: DockConfiguration.tileSizeRange) { + Text("Size") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) + } + .labelsHidden() + .frame(maxWidth: 260) if let apple = store.appleDock.tileSize, abs(apple - config.tileSize) >= 1 { Button("Match Apple Dock") { store.update { $0.tileSize = apple } } .controlSize(.small) @@ -146,13 +157,52 @@ struct DockSettingsPage: View { } if config.style == .shelf { LabeledContent("Widget size") { - Slider(value: binding(\.widgetSize), in: DockConfiguration.widgetSizeRange, step: 2) - .frame(maxWidth: 220) + Slider(value: binding(\.widgetSize), in: DockConfiguration.widgetSizeRange) { + Text("Widget size") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) + } + .labelsHidden() + .frame(maxWidth: 260) + } + } + if config.style == .classic { + Toggle("Magnification", isOn: binding(\.magnification)) + LabeledContent("Magnification amount") { + Slider(value: binding(\.magnificationAmount), in: DockConfiguration.magnificationAmountRange) { + Text("Magnification amount") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) + } + .labelsHidden() + .frame(maxWidth: 260) } + .disabled(!config.magnification) + } + Picker("Show on", selection: binding(\.display)) { + Text("Main display").tag(DockConfiguration.Display.main) + Text("Display with the pointer").tag(DockConfiguration.Display.pointer) } - Toggle("Magnification", isOn: binding(\.magnification)) - .disabled(config.style != .classic) Toggle("Automatically hide and show the dock", isOn: binding(\.autoHide)) + if config.autoHide { + LabeledContent("Show after") { + HStack { + Slider(value: binding(\.autoHideDelay), in: DockConfiguration.autoHideDelayRange) + .frame(maxWidth: 200) + Text(config.autoHideDelay < 0.05 + ? String(localized: "Immediately") + : String(localized: "\(config.autoHideDelay, format: .number.precision(.fractionLength(1))) s")) + .monospacedDigit() + .frame(minWidth: 70, alignment: .trailing) + } + } + } + Toggle("Animate opening apps", isOn: binding(\.animateOpening)) + Toggle("Show indicators for open apps", isOn: binding(\.showIndicators)) Toggle("Show open apps that aren't in the dock", isOn: binding(\.showRunningApps)) Toggle("Show Trash", isOn: binding(\.showTrash)) } diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift index 35520c4..e3b85d4 100644 --- a/Sources/MenubucketApp/Dock/DockView.swift +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -89,10 +89,15 @@ struct DockView: View { tiles[hovered].magnifies, tiles[index].magnifies else { return 1 } let distance = CGFloat(abs(index - hovered)) - return 1 + Self.maxMagnification * max(0, 1 - distance / 2.5) + return 1 + Self.magnification(for: config) * max(0, 1 - distance / 2.5) } - static let maxMagnification: CGFloat = 0.5 + /// How much the hovered icon grows, as a fraction of its size; 0 when + /// this dock does not magnify. + static func magnification(for config: DockConfiguration) -> CGFloat { + guard config.style == .classic, config.magnification else { return 0 } + return CGFloat(config.magnificationAmount) + } // MARK: Body @@ -237,6 +242,7 @@ struct DockView: View { id: item.id, title: DockActions.displayName(for: item), image: DockActions.icon(for: item), isRunning: running.isRunning(path: path), scale: scale, + isLaunching: running.isLaunching(path: path), action: { DockActions.open(item) }, dropFiles: { urls in DockActions.open(urls, withAppAt: path) }, menu: { itemMenu(item) } @@ -312,6 +318,7 @@ struct DockView: View { image: NSImage?, isRunning: Bool, scale: CGFloat, + isLaunching: Bool = false, action: @escaping () -> Void, dropFiles: (([URL]) -> Void)?, custom: AnyView? = nil, @@ -336,6 +343,7 @@ struct DockView: View { } .frame(width: tileSize, height: tileSize) .scaleEffect(scale, anchor: anchor) + .modifier(LaunchBounce(isActive: isLaunching, edge: edge, height: tileSize * 0.45)) .frame( width: edge.isVertical ? tileSize : grown, height: edge.isVertical ? grown : tileSize, @@ -352,7 +360,7 @@ struct DockView: View { } } .overlay(alignment: runningDotAlignment) { - if isRunning { + if isRunning, config.showIndicators { Circle() .fill(Color.primary.opacity(0.7)) .frame(width: isClassic ? 3 : 4, height: isClassic ? 3 : 4) @@ -785,3 +793,27 @@ private struct OuterItemMenu: ViewModifier { } } } + +/// An icon hopping off the dock while its app launches. +private struct LaunchBounce: ViewModifier { + let isActive: Bool + let edge: DockConfiguration.Edge + let height: CGFloat + + func body(content: Content) -> some View { + if isActive { + TimelineView(.animation) { context in + // Hops of 0.6 s, easing at the top like a thrown ball. + let phase = context.date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: 0.6) / 0.6 + let lift = height * CGFloat(sin(phase * .pi)) + content.offset( + x: edge == .left ? lift : edge == .right ? -lift : 0, + y: edge == .bottom ? -lift : 0 + ) + } + } else { + content + } + } +} diff --git a/Sources/MenubucketCore/Dock.swift b/Sources/MenubucketCore/Dock.swift index 6b567d2..43f900c 100644 --- a/Sources/MenubucketCore/Dock.swift +++ b/Sources/MenubucketCore/Dock.swift @@ -49,6 +49,25 @@ public struct DockConfiguration: Codable, Equatable, Sendable { /// Apps that are running but not in the profile, after a separator. public var showRunningApps: Bool public var showTrash: Bool + /// How much a hovered icon grows: 0 is barely, 1 is double size. + public var magnificationAmount: Double = DockConfiguration.defaultMagnificationAmount + /// The dots under running apps. + public var showIndicators: Bool = true + /// An app bounces while it launches from the dock. + public var animateOpening: Bool = true + /// Seconds the pointer rests at the edge before an auto-hidden dock + /// comes out. + public var autoHideDelay: Double = DockConfiguration.defaultAutoHideDelay + /// Which display the dock is on. + public var display: Display = .main + + public enum Display: String, Codable, CaseIterable, Sendable { + /// The display with the menu bar. + case main + /// Follows the pointer to the display it rests at the edge of, as the + /// Apple Dock does. + case pointer + } public var profiles: [DockProfile] public var activeProfileID: String @@ -66,6 +85,10 @@ public struct DockConfiguration: Codable, Equatable, Sendable { public static let widgetSizeRange: ClosedRange = 72...220 public static let defaultTileSize: Double = 52 public static let defaultWidgetSize: Double = 110 + public static let magnificationAmountRange: ClosedRange = 0.1...1 + public static let defaultMagnificationAmount: Double = 0.5 + public static let autoHideDelayRange: ClosedRange = 0...2 + public static let defaultAutoHideDelay: Double = 0.2 /// Profiles past this many cannot get a ⌃⌥ number. public static let hotkeyProfileLimit = 9 @@ -108,6 +131,10 @@ public struct DockConfiguration: Codable, Equatable, Sendable { public mutating func normalize() { tileSize = Self.clamp(tileSize, to: Self.tileSizeRange, fallback: Self.defaultTileSize) widgetSize = Self.clamp(widgetSize, to: Self.widgetSizeRange, fallback: Self.defaultWidgetSize) + magnificationAmount = Self.clamp( + magnificationAmount, to: Self.magnificationAmountRange, fallback: Self.defaultMagnificationAmount + ) + autoHideDelay = Self.clamp(autoHideDelay, to: Self.autoHideDelayRange, fallback: Self.defaultAutoHideDelay) if profiles.isEmpty { profiles = [DockProfile(id: DockProfile.defaultID, name: DockProfile.defaultName)] } @@ -172,6 +199,7 @@ public struct DockConfiguration: Codable, Equatable, Sendable { case mode, style, edge, tileSize, widgetSize, magnification, autoHide case showRunningApps, showTrash, profiles, activeProfileID case profileHotkeysEnabled, appleDockLayouts, appleDockBackup + case magnificationAmount, showIndicators, animateOpening, autoHideDelay, display } public init(from decoder: Decoder) throws { @@ -191,6 +219,12 @@ public struct DockConfiguration: Codable, Equatable, Sendable { profileHotkeysEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .profileHotkeysEnabled)) ?? false appleDockLayouts = (try? c.decodeIfPresent(Bool.self, forKey: .appleDockLayouts)) ?? false appleDockBackup = (try? c.decodeIfPresent(AppleDockVisibility.self, forKey: .appleDockBackup)) ?? nil + magnificationAmount = (try? c.decodeIfPresent(Double.self, forKey: .magnificationAmount)) + ?? defaults.magnificationAmount + showIndicators = (try? c.decodeIfPresent(Bool.self, forKey: .showIndicators)) ?? defaults.showIndicators + animateOpening = (try? c.decodeIfPresent(Bool.self, forKey: .animateOpening)) ?? defaults.animateOpening + autoHideDelay = (try? c.decodeIfPresent(Double.self, forKey: .autoHideDelay)) ?? defaults.autoHideDelay + display = (try? c.decodeIfPresent(Display.self, forKey: .display)) ?? defaults.display normalize() } diff --git a/Tests/MenubucketCoreTests/DockConfigurationTests.swift b/Tests/MenubucketCoreTests/DockConfigurationTests.swift index ea55d2f..aa3a86a 100644 --- a/Tests/MenubucketCoreTests/DockConfigurationTests.swift +++ b/Tests/MenubucketCoreTests/DockConfigurationTests.swift @@ -66,6 +66,28 @@ final class DockConfigurationTests: XCTestCase { XCTAssertEqual(config.activeProfile.items.map(\.id), ["a", "s"]) } + func testAppleDockStyleSettingsRoundTripAndClamp() throws { + var config = DockConfiguration() + config.magnificationAmount = 7 + config.autoHideDelay = -1 + config.showIndicators = false + config.animateOpening = false + config.display = .pointer + config.normalize() + XCTAssertEqual(config.magnificationAmount, DockConfiguration.magnificationAmountRange.upperBound) + XCTAssertEqual(config.autoHideDelay, 0) + let file = tempDir.appendingPathComponent("dock.json") + try config.save(to: file) + XCTAssertEqual(DockConfiguration.load(from: file), config) + + // Files from before these settings get the Apple Dock's defaults. + let old = try JSONDecoder().decode(DockConfiguration.self, from: Data(#"{"mode":"alongside"}"#.utf8)) + XCTAssertTrue(old.showIndicators) + XCTAssertTrue(old.animateOpening) + XCTAssertEqual(old.display, .main) + XCTAssertEqual(old.magnificationAmount, DockConfiguration.defaultMagnificationAmount) + } + func testUnknownModeFallsBackAndSizesClamp() throws { let json = #"{"mode":"teleport","tileSize":4000,"widgetSize":-3}"# let config = try JSONDecoder().decode(DockConfiguration.self, from: Data(json.utf8)) diff --git a/docs/DOCK.md b/docs/DOCK.md index f5a3662..5fc5e35 100644 --- a/docs/DOCK.md +++ b/docs/DOCK.md @@ -45,6 +45,11 @@ Drag apps, folders, files, or web links onto the dock to add them. Drag an item onto another to move it. Right-click any item for its menu, including **Remove from Dock**. +The Apple Dock's own settings are there too: size, magnification and how +much, which display (the main one, or the one the pointer rests at the edge +of), auto-hide and how long the pointer waits at the edge, opening +animation, and indicators for open apps. + **Classic** looks like the Apple Dock: its icon size by default, icons on clear Liquid Glass (macOS 26 and later), names on hover, optional magnification. Widgets keep to icon height there, showing their name and main From fcd3073e8bfbc1d55209355b8935f7a4427041cb Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 16:55:15 +0900 Subject: [PATCH 06/10] fix(dock): move out of the Apple Dock's way when it changes displays The Apple Dock follows the pointer between displays, which changes a display's visible frame with no notification; the BarShelf Dock kept its old place and ended up under it. The frame is re-checked as the pointer moves, the only thing that moves the Apple Dock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../MenubucketApp/Dock/DockPanelController.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index 54cbd60..1e5823c 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -324,6 +324,7 @@ final class DockPanelController { guard config.mode.showsDock else { return } let point = NSEvent.mouseLocation followPointerIfNeeded(point) + replaceIfAppleDockMoved() guard config.autoHide, let screen else { return } let atEdge = isAtEdge(point, of: screen) if !isRevealed { @@ -362,6 +363,18 @@ final class DockPanelController { } } + /// The Apple Dock moves between displays with the pointer, which changes + /// a display's visible frame without any notification. Sitting on a + /// stale frame put the BarShelf Dock under it, so the frame is + /// re-checked as the pointer moves (it is the pointer that moves it). + private func replaceIfAppleDockMoved() { + guard let visible = screen?.visibleFrame, visible != lastVisibleFrame else { return } + lastVisibleFrame = visible + placePanel(animated: true) + } + + private var lastVisibleFrame: NSRect? + /// Following the pointer: resting at the dock's edge of another display /// moves the dock there, as the Apple Dock does. private func followPointerIfNeeded(_ point: NSPoint) { From f7fa19b3cce9b558a0b055f6c7c564c4d34853b9 Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 7 Oct 2026 17:23:29 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix(dock):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20safe=20Apple=20Dock=20writes,=20CLI=20restore,=20ho?= =?UTF-8?q?tkey=20conflicts,=20menus,=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a review of #94: - Never write an empty Apple Dock layout (it would unpin every app); refuse to save one when the Dock's prefs can't be read. - `barshelf dock restore-apple-dock` asks a running BarShelf instead of editing dock.json under it (the app saved its stale copy back). - Deleting the active profile switches to the next one properly: its Apple Dock layout and popup page, not only its items. - The copyable Focus link names the profile by id, surviving renames. - Profile names save on Return / leaving the field, not per keystroke (trimming ate spaces while typing). - ⌃⌥ numbers another shortcut owns are marked taken, and retried. - A right-click after a tile vanished no longer shows its menu. - ⌘-scroll switches one profile per gesture, not a run of them. - Icons and names are cached per path; the global mouse monitor runs only for auto-hide, following the pointer, or multi-display alongside. Co-Authored-By: Claude Opus 5.5 (1M context) --- Localization/Localizable.xcstrings | 30 +++++++++++ .../BarShelfCLI/BarShelfKit/DockCommand.swift | 21 +++++++- Sources/MenubucketApp/Dock/DockActions.swift | 30 +++++++++-- Sources/MenubucketApp/Dock/DockHotkeys.swift | 22 +++++++- Sources/MenubucketApp/Dock/DockMenus.swift | 7 +++ .../Dock/DockPanelController.swift | 32 ++++++++++-- .../MenubucketApp/Dock/DockSettingsPage.swift | 47 ++++++++++++++--- Sources/MenubucketApp/Dock/DockStore.swift | 30 ++++++++++- Sources/MenubucketApp/Dock/DockView.swift | 9 ++-- .../MenubucketApp/StatusItemController.swift | 2 + Sources/MenubucketCore/AppleDock.swift | 6 +++ Tests/BarShelfCLITests/DockCommandTests.swift | 20 +++++++- Tests/MenubucketAppTests/DockStoreTests.swift | 50 +++++++++++++++++-- .../DockConfigurationTests.swift | 13 +++++ docs/DOCK.md | 10 +++- 15 files changed, 300 insertions(+), 29 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 9508ce5..170d185 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -115,6 +115,16 @@ } } }, + "%@ is taken": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 사용 중" + } + } + } + }, "%@ needs your permission to run.": { "localizations": { "ko": { @@ -988,6 +998,16 @@ } } }, + "Another app or an Automation shortcut already uses %@. Free it, then turn profile shortcuts off and on.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 앱이나 Automation 단축키가 이미 %@을(를) 쓰고 있습니다. 비운 뒤 프로필 단축키를 껐다가 다시 켜세요." + } + } + } + }, "App": { "comment": "Menu bar icon choice", "localizations": { @@ -2257,6 +2277,16 @@ } } }, + "Couldn't read the Apple Dock's layout, so nothing was saved.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock 레이아웃을 읽지 못해 아무것도 저장하지 않았습니다." + } + } + } + }, "Couldn't remove widget": { "localizations": { "ko": { diff --git a/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift b/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift index c136517..b24387c 100644 --- a/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift +++ b/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift @@ -26,7 +26,8 @@ public enum DockCommand { arguments: [String], configurationURL: URL = DockCommand.configurationURL, openURL: (URL) -> Bool = DockCommand.openInApp, - appleDock: () -> AppleDock = { AppleDock() } + appleDock: () -> AppleDock = { AppleDock() }, + appIsRunning: () -> Bool = DockCommand.appIsRunning ) -> Int32 { guard let subcommand = arguments.first else { BarShelfMain.printError(usage) @@ -56,6 +57,13 @@ public enum DockCommand { case "previous", "prev": return send(query: "previous", openURL: openURL) case "restore-apple-dock": + // A running BarShelf owns dock.json and would save its own copy + // over an edit made here, hiding the Dock again at next launch. + if appIsRunning() { + let status = send(query: "restore-apple-dock", openURL: openURL) + if status == 0 { print("asked BarShelf to bring back the Apple Dock") } + return status + } let dock = appleDock() var config = DockConfiguration.load(from: configurationURL) if let backup = config.appleDockBackup { @@ -101,6 +109,17 @@ public enum DockCommand { return 0 } + public static func appIsRunning() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-x", "barshelf-app"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return false } + process.waitUntilExit() + return process.terminationStatus == 0 + } + /// `open -g` hands the URL to BarShelf without bringing anything forward. public static func openInApp(_ url: URL) -> Bool { let process = Process() diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift index 5d25a04..baaf08f 100644 --- a/Sources/MenubucketApp/Dock/DockActions.swift +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -96,6 +96,27 @@ final class RunningApps: ObservableObject { /// What clicking, dropping on, and right-clicking a dock tile does. enum DockActions { + // Icons and names, once per path. The dock's body runs on every hover + // change while magnifying, and asking LaunchServices for every tile's + // icon each time stuttered the animation. + private static let iconCache = NSCache() + private static let nameCache = NSCache() + + static func fileIcon(at path: String) -> NSImage { + if let cached = iconCache.object(forKey: path as NSString) { return cached } + let icon = NSWorkspace.shared.icon(forFile: path) + iconCache.setObject(icon, forKey: path as NSString) + return icon + } + + static func fileDisplayName(at path: String) -> String { + if let cached = nameCache.object(forKey: path as NSString) { return cached as String } + let name = FileManager.default.displayName(atPath: path) + .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]) + nameCache.setObject(name as NSString, forKey: path as NSString) + return name + } + static var trashURL: URL { FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash", isDirectory: true) } @@ -195,12 +216,12 @@ enum DockActions { static func icon(for item: DockItem) -> NSImage? { switch item.kind { case .app(let path), .file(let path): - return NSWorkspace.shared.icon(forFile: path) + return fileIcon(at: path) case .folder(let path, _, _): - return NSWorkspace.shared.icon(forFile: path) + return fileIcon(at: path) case .shortcut: return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.shortcuts") - .map { NSWorkspace.shared.icon(forFile: $0.path) } + .map { fileIcon(at: $0.path) } default: return nil } @@ -209,8 +230,7 @@ enum DockActions { static func displayName(for item: DockItem) -> String { switch item.kind { case .app(let path), .file(let path): - return FileManager.default.displayName(atPath: path) - .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]) + return fileDisplayName(at: path) default: return item.fallbackTitle } diff --git a/Sources/MenubucketApp/Dock/DockHotkeys.swift b/Sources/MenubucketApp/Dock/DockHotkeys.swift index 4104a46..8563f3b 100644 --- a/Sources/MenubucketApp/Dock/DockHotkeys.swift +++ b/Sources/MenubucketApp/Dock/DockHotkeys.swift @@ -6,6 +6,13 @@ import MenubucketCore /// ⌃⌥1…9: switch to the first nine dock profiles (R15). Carbon hot keys, like /// the popup shortcut, so no Accessibility permission is needed. Its own /// signature keeps its presses apart from the popup shortcut's handler. +/// Which ⌃⌥ numbers another shortcut already owns — another app, or an +/// Automation shortcut — for settings to show. Positions are 1-based. +final class DockHotkeyStatus: ObservableObject { + static let shared = DockHotkeyStatus() + @Published fileprivate(set) var unavailable: Set = [] +} + final class DockHotkeys { static let signature: OSType = 0x4253_444B // 'BSDK' static let modifiers = UInt32(controlKey | optionKey) @@ -19,6 +26,9 @@ final class DockHotkeys { private var refs: [EventHotKeyRef] = [] private var handler: EventHandlerRef? private var registeredCount = 0 + private var unavailable: Set = [] { + didSet { DockHotkeyStatus.shared.unavailable = unavailable } + } private var cancellable: AnyCancellable? init(store: DockStore) { @@ -42,26 +52,34 @@ final class DockHotkeys { } private func register(count: Int) { - guard count != registeredCount else { return } + // Same count and nothing failed last time: nothing to do. A failure + // is retried, since whatever held the key may have let it go. + guard count != registeredCount || !unavailable.isEmpty else { return } unregisterAll() guard count > 0 else { return } installHandlerIfNeeded() + var failed: Set = [] for (index, keyCode) in Self.digitKeyCodes.prefix(count).enumerated() { var ref: EventHotKeyRef? let id = EventHotKeyID(signature: Self.signature, id: UInt32(index + 1)) - // A combination another app already owns just stays unregistered. if RegisterEventHotKey(keyCode, Self.modifiers, id, GetApplicationEventTarget(), 0, &ref) == noErr, let ref { refs.append(ref) + } else { + failed.insert(index + 1) } } registeredCount = count + unavailable = failed } + + private func unregisterAll() { refs.forEach { UnregisterEventHotKey($0) } refs.removeAll() registeredCount = 0 + unavailable = [] } private func installHandlerIfNeeded() { diff --git a/Sources/MenubucketApp/Dock/DockMenus.swift b/Sources/MenubucketApp/Dock/DockMenus.swift index 032735f..dbe66b1 100644 --- a/Sources/MenubucketApp/Dock/DockMenus.swift +++ b/Sources/MenubucketApp/Dock/DockMenus.swift @@ -130,6 +130,13 @@ final class DockMenuRouter { if hovered?.id == id { hovered = nil } } + /// Forgets a hovered tile that is gone. SwiftUI does not always say the + /// pointer left a view that was removed under it (Remove from Dock, an + /// app quitting), which left a right-click showing the gone tile's menu. + func keep(only ids: Set) { + if let hovered, !ids.contains(hovered.id) { self.hovered = nil } + } + /// The entries for a right-click right now. var current: [DockMenuEntry]? { if let hovered { return hovered.entries() } diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index 1e5823c..2b6cb4b 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -40,6 +40,8 @@ final class DockHostingView: NSHostingView { var onIdealSizeChange: (() -> Void)? private var accumulated: CGFloat = 0 private var firedThisGesture = false + private var commandScrollFired = false + private var lastCommandScroll: TimeInterval = 0 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } @@ -70,9 +72,17 @@ final class DockHostingView: NSHostingView { // Momentum after the fingers lift would switch a second time. guard event.momentumPhase.isEmpty else { return } if event.modifierFlags.contains(.command) { - // A mouse wheel notch, or a decent trackpad flick: one profile. + // One profile per gesture or wheel notch: a trackpad flick sends + // a run of events, and each switch may restart the Apple Dock. let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY / 20 : event.scrollingDeltaY - if abs(delta) >= 1 { onSwipe?(delta > 0 ? -1 : 1) } + let now = ProcessInfo.processInfo.systemUptime + if event.phase == .began { commandScrollFired = false } + if abs(delta) >= 1, !commandScrollFired, now - lastCommandScroll > 0.35 { + commandScrollFired = event.hasPreciseScrollingDeltas + lastCommandScroll = now + onSwipe?(delta > 0 ? -1 : 1) + } + if event.phase == .ended || event.phase == .cancelled { commandScrollFired = false } return } guard event.hasPreciseScrollingDeltas, abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) else { @@ -162,7 +172,8 @@ final class DockPanelController { .store(in: &cancellables) NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) .receive(on: RunLoop.main) - .sink { [weak self] _ in self?.placePanel(animated: false) } + // Displays coming and going change whether the pointer matters. + .sink { [weak self] _ in self?.configurationChanged() } .store(in: &cancellables) configurationChanged() } @@ -182,7 +193,11 @@ final class DockPanelController { runtime.setDockWidgetIDs([]) return } - installMouseMonitorsIfNeeded() + if needsPointerTracking { + installMouseMonitorsIfNeeded() + } else { + removeMouseMonitors() + } if !config.autoHide { isRevealed = true } setNeedsMeasure() placePanel(animated: false) @@ -301,6 +316,15 @@ final class DockPanelController { // MARK: Auto-hide + /// Watching every mouse move system-wide is only worth it for what + /// needs the pointer: auto-hide, following it between displays, and + /// stepping aside when the Apple Dock moves onto this display (which + /// only happens with more than one display). + private var needsPointerTracking: Bool { + config.autoHide || config.display == .pointer + || (config.mode == .alongside && NSScreen.screens.count > 1) + } + private func installMouseMonitorsIfNeeded() { guard mouseMonitors.isEmpty else { return } let handler: (NSEvent) -> Void = { [weak self] _ in self?.mouseMoved() } diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index 9d40a48..160105b 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -16,6 +16,9 @@ struct DockSettingsPage: View { @State private var newProfileName = "" @State private var showNewProfilePrompt = false @State private var profileToDelete: DockProfile? + @ObservedObject private var hotkeyStatus = DockHotkeyStatus.shared + @State private var nameDraft = "" + @FocusState private var nameFocused: Bool static let symbolPresets = [ "square.grid.2x2", "briefcase", "house", "laptopcomputer", "hammer", @@ -262,7 +265,14 @@ struct DockSettingsPage: View { } Spacer() if config.profileHotkeysEnabled, let label = DockHotkeys.label(forPosition: position) { - Text(label).font(.caption.monospaced()).foregroundStyle(.secondary) + if hotkeyStatus.unavailable.contains(position) { + Label("\(label) is taken", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .help("Another app or an Automation shortcut already uses \(label). Free it, then turn profile shortcuts off and on.") + } else { + Text(label).font(.caption.monospaced()).foregroundStyle(.secondary) + } } if !isActive { Button("Switch") { store.activate(profileID: profile.id) } @@ -279,10 +289,23 @@ struct DockSettingsPage: View { private var itemsSection: some View { let profile = selectedProfile return Section { - TextField("Name", text: Binding( - get: { profile.name }, - set: { name in store.updateProfile(profile.id) { $0.name = name } } - )) + // Saved on Return or leaving the field: written per keystroke, + // the name was trimmed while typing ("Work Mode" lost its space). + TextField("Name", text: $nameDraft) + .focused($nameFocused) + .onSubmit { commitName(profileID: profile.id) } + .onChange(of: nameFocused) { _, focused in + if !focused { commitName(profileID: profile.id) } + } + .onChange(of: profile.id, initial: true) { previous, current in + // Switching profiles mid-edit keeps the edit, on the + // profile it was made to. + if previous != current { commitName(profileID: previous) } + nameDraft = profile.name + } + .onChange(of: profile.name) { _, name in + if !nameFocused { nameDraft = name } + } Picker("Symbol", selection: Binding( get: { profile.symbol }, set: { symbol in store.updateProfile(profile.id) { $0.symbol = symbol } } @@ -317,6 +340,16 @@ struct DockSettingsPage: View { } } + private func commitName(profileID: String) { + let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + nameDraft = config.profiles.first { $0.id == profileID }?.name ?? "" + return + } + guard config.profiles.first(where: { $0.id == profileID })?.name != name else { return } + store.updateProfile(profileID) { $0.name = name } + } + private func itemRow(_ item: DockItem, profileID: String) -> some View { HStack(spacing: Spacing.xs) { itemIcon(item) @@ -536,8 +569,10 @@ struct DockSettingsPage: View { } } + /// By id, which survives a rename; a name link would quietly stop + /// working in a Focus automation once the profile is renamed. static func switchURL(for profile: DockProfile) -> String { - let value = profile.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))) ?? profile.id + let value = profile.id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))) ?? profile.id return "barshelf://dock?profile=\(value)" } } diff --git a/Sources/MenubucketApp/Dock/DockStore.swift b/Sources/MenubucketApp/Dock/DockStore.swift index 0565da9..7a840e1 100644 --- a/Sources/MenubucketApp/Dock/DockStore.swift +++ b/Sources/MenubucketApp/Dock/DockStore.swift @@ -117,10 +117,16 @@ final class DockStore: ObservableObject { } func removeProfile(_ id: String) { + let wasActive = configuration.activeProfileID == id update { config in guard config.profiles.count > 1 else { return } config.profiles.removeAll { $0.id == id } } + // The profile that takes over is switched to properly: its Apple + // Dock layout and popup page, not just its items. + if wasActive, configuration.activeProfileID != id { + activate(profileID: configuration.activeProfileID) + } } // MARK: Switching @@ -165,10 +171,30 @@ final class DockStore: ObservableObject { // MARK: Apple Dock layouts - /// Saves what the Apple Dock shows now into a profile. - func captureAppleDock(into profileID: String) { + /// Saves what the Apple Dock shows now into a profile. False when its + /// layout could not be read; an empty save would unpin everything later. + @discardableResult + func captureAppleDock(into profileID: String) -> Bool { let layout = appleDock.currentLayout() + guard !layout.isEmpty else { + lastError = String(localized: "Couldn't read the Apple Dock's layout, so nothing was saved.") + return false + } updateProfile(profileID) { $0.appleDock = layout } + return true + } + + /// `barshelf dock restore-apple-dock` while BarShelf runs: the app owns + /// dock.json then, so the CLI asks it instead of editing the file. + func restoreAppleDockOnRequest() { + if configuration.mode == .replace { + update { $0.mode = .alongside } + } else if configuration.appleDockBackup != nil { + restoreAppleDock() + save() + } else { + appleDock.unhideWithoutBackup() + } } /// Puts a profile's saved layout into the Apple Dock now. diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift index e3b85d4..5b985e1 100644 --- a/Sources/MenubucketApp/Dock/DockView.swift +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -124,6 +124,10 @@ struct DockView: View { .onChange(of: hoveredID == nil) { _, resting in onHoverChange(!resting) } .animation(.spring(response: 0.22, dampingFraction: 0.82), value: hoveredID) .animation(.easeInOut(duration: 0.2), value: tiles.map(\.id)) + .onChange(of: tiles.map(\.id)) { _, ids in + DockMenuRouter.shared.keep(only: Set(ids)) + if let hoveredID, !ids.contains(hoveredID) { self.hoveredID = nil } + } // The panel is larger than the bar (room for magnified icons and // labels); the bar sits on the screen edge inside it. .padding(edgeInsets) @@ -201,9 +205,8 @@ struct DockView: View { case .running(let app): iconTile( id: tile.id, - title: FileManager.default.displayName(atPath: app.path) - .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]), - image: NSWorkspace.shared.icon(forFile: app.path), + title: DockActions.fileDisplayName(at: app.path), + image: DockActions.fileIcon(at: app.path), isRunning: true, scale: scale, action: { DockActions.openApp(path: app.path) }, dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) }, diff --git a/Sources/MenubucketApp/StatusItemController.swift b/Sources/MenubucketApp/StatusItemController.swift index 1be8431..4b3f812 100644 --- a/Sources/MenubucketApp/StatusItemController.swift +++ b/Sources/MenubucketApp/StatusItemController.swift @@ -477,6 +477,8 @@ final class StatusItemController: NSObject { store.activate(offset: 1) case "previous", "prev": store.activate(offset: -1) + case "restore-apple-dock": + store.restoreAppleDockOnRequest() default: continue } diff --git a/Sources/MenubucketCore/AppleDock.swift b/Sources/MenubucketCore/AppleDock.swift index b69e2d8..dd9f0ba 100644 --- a/Sources/MenubucketCore/AppleDock.swift +++ b/Sources/MenubucketCore/AppleDock.swift @@ -21,6 +21,9 @@ public struct AppleDockLayout: Codable, Equatable, Sendable { } public var appTiles: [Any] { Self.decode(apps) } + /// Nothing readable: the prefs could not be read when it was saved, or + /// the stored data is damaged. + public var isEmpty: Bool { appTiles.isEmpty && otherTiles.isEmpty } public var otherTiles: [Any] { Self.decode(others) } static func encode(_ tiles: [Any]) -> Data { @@ -201,6 +204,9 @@ public final class AppleDock { let current = currentLayout() let newApps = layout.appTiles let newOthers = layout.otherTiles + // A layout with nothing in it is an unreadable save, not a wish for + // an empty Dock: writing it would unpin every app. + guard !newApps.isEmpty || !newOthers.isEmpty else { return false } if AppleDockTiles.signature(of: current.appTiles) == AppleDockTiles.signature(of: newApps), AppleDockTiles.signature(of: current.otherTiles) == AppleDockTiles.signature(of: newOthers) { return false diff --git a/Tests/BarShelfCLITests/DockCommandTests.swift b/Tests/BarShelfCLITests/DockCommandTests.swift index bf25983..dbcbc4d 100644 --- a/Tests/BarShelfCLITests/DockCommandTests.swift +++ b/Tests/BarShelfCLITests/DockCommandTests.swift @@ -62,7 +62,8 @@ final class DockCommandTests: XCTestCase { let status = DockCommand.run( arguments: ["restore-apple-dock"], configurationURL: fileURL, - appleDock: { AppleDock(defaults: defaults) {} } + appleDock: { AppleDock(defaults: defaults) {} }, + appIsRunning: { false } ) XCTAssertEqual(status, 0) XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, false) @@ -72,6 +73,23 @@ final class DockCommandTests: XCTestCase { XCTAssertEqual(saved.mode, .alongside) } + /// With BarShelf running the app is asked, and the file is left alone. + func testRestoreAsksARunningApp() throws { + var config = DockConfiguration.load(from: fileURL) + config.mode = .replace + try config.save(to: fileURL) + var opened: [URL] = [] + let status = DockCommand.run( + arguments: ["restore-apple-dock"], configurationURL: fileURL, + openURL: { opened.append($0); return true }, + appleDock: { XCTFail("the app does this"); return AppleDock(defaults: FakeDefaults()) {} }, + appIsRunning: { true } + ) + XCTAssertEqual(status, 0) + XCTAssertEqual(opened, [URL(string: "barshelf://dock?restore-apple-dock")!]) + XCTAssertEqual(DockConfiguration.load(from: fileURL).mode, .replace) + } + func testUnknownSubcommandFails() { XCTAssertEqual(DockCommand.run(arguments: ["fly"], configurationURL: fileURL), 1) XCTAssertEqual(DockCommand.run(arguments: [], configurationURL: fileURL), 1) diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift index cf5f6c8..a990bc6 100644 --- a/Tests/MenubucketAppTests/DockStoreTests.swift +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -164,11 +164,55 @@ final class DockStoreTests: XCTestCase { XCTAssertEqual(received[1].first?.name, "next") } - func testSwitchURLRoundTripsAName() { - let profile = DockProfile(id: "x", name: "Deep Work & Co") + /// The copyable link names the profile by id, so renaming it does not + /// quietly break a Focus automation. + func testSwitchURLUsesTheStableID() { + let profile = DockProfile(id: "a&b", name: "Deep Work") let url = URL(string: DockSettingsPage.switchURL(for: profile))! let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value - XCTAssertEqual(value, "Deep Work & Co") + XCTAssertEqual(value, "a&b") + } + + func testDeletingTheActiveProfileSwitchesProperly() { + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Mail.app")] + let store = makeStore() + store.captureAppleDock(into: DockProfile.defaultID) + let work = store.addProfile(named: "Work") + store.update { $0.appleDockLayouts = true } + store.activate(profileID: work) + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Music.app")] + var activated: [String] = [] + store.onProfileActivated = { activated.append($0.id) } + + store.removeProfile(work) + XCTAssertEqual(store.configuration.activeProfileID, DockProfile.defaultID) + XCTAssertEqual(activated, [DockProfile.defaultID]) + XCTAssertEqual(AppleDockTiles.labels(of: defaults.values[AppleDock.appsKey] as! [Any]), ["Mail"]) + } + + func testAnUnreadableAppleDockIsNotSaved() { + let store = makeStore() + XCTAssertFalse(store.captureAppleDock(into: DockProfile.defaultID)) + XCTAssertNil(store.activeProfile.appleDock) + XCTAssertNotNil(store.lastError) + } + + func testRestoreRequestedByTheCLILeavesReplaceMode() { + let store = makeStore() + store.update { $0.mode = .replace } + store.restoreAppleDockOnRequest() + XCTAssertEqual(store.configuration.mode, .alongside) + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertNil(DockConfiguration.load(from: fileURL).appleDockBackup) + } + + func testMenuRouterForgetsAGoneTile() { + let router = DockMenuRouter() + router.bar = { [.action("Dock Settings…") {}] } + router.enter("mail", entries: { [.action("Quit") {}] }) + XCTAssertEqual(router.current?.map(\.title), ["Quit"]) + router.keep(only: ["notes"]) + XCTAssertEqual(router.current?.map(\.title), ["Dock Settings…"]) } } diff --git a/Tests/MenubucketCoreTests/DockConfigurationTests.swift b/Tests/MenubucketCoreTests/DockConfigurationTests.swift index aa3a86a..e9ab6a9 100644 --- a/Tests/MenubucketCoreTests/DockConfigurationTests.swift +++ b/Tests/MenubucketCoreTests/DockConfigurationTests.swift @@ -224,6 +224,19 @@ final class AppleDockTests: XCTestCase { XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) } + /// An empty layout is an unreadable save; writing it would unpin + /// every app in the Dock. + func testAnEmptyLayoutIsNeverWritten() { + let defaults = FakeDefaults() + defaults.values[AppleDock.appsKey] = [tile("/Applications/Mail.app", guid: 1)] + var restarts = 0 + let dock = AppleDock(defaults: defaults) { restarts += 1 } + XCTAssertFalse(dock.apply(AppleDockLayout(appTiles: [], otherTiles: []))) + XCTAssertEqual(restarts, 0) + XCTAssertEqual((defaults.values[AppleDock.appsKey] as? [Any])?.count, 1) + XCTAssertTrue(AppleDockLayout(appTiles: [], otherTiles: []).isEmpty) + } + func testLabelsSkipSpacers() { let tiles: [Any] = [ tile("/Applications/Mail.app", guid: 1), diff --git a/docs/DOCK.md b/docs/DOCK.md index 5fc5e35..e576789 100644 --- a/docs/DOCK.md +++ b/docs/DOCK.md @@ -21,6 +21,9 @@ launch puts them back too. If the Apple Dock ever stays hidden anyway: barshelf dock restore-apple-dock ``` +With BarShelf running, this asks the app to put the Apple Dock back and leave +"Instead of the Apple Dock"; otherwise it restores the saved settings itself. + macOS has no public way to make other apps' windows stay clear of a dock that isn't Apple's. Windows can go under the BarShelf Dock, as they do under an auto-hidden Apple Dock. Turn on **Automatically hide and show the dock** if @@ -69,10 +72,13 @@ Each profile has its own items. It can also carry: Ways to switch: -- **⌃⌥1–9** for the first nine profiles (turn on in **Switching**). +- **⌃⌥1–9** for the first nine profiles (turn on in **Switching**). A number + another app or an Automation shortcut already uses is marked as taken. - A **two-finger sideways swipe** on the dock, or **⌘-scroll** over it. - **BarShelf menu ▸ Dock**, or right-click the dock. -- A link: `barshelf://dock?profile=Work` (name, id, or number), +- A link: `barshelf://dock?profile=Work` (name, id, or number; the link + **Dock ▸ Switching** copies uses the id, so renaming the profile does not + break it), `barshelf://dock?next`, `barshelf://dock?previous`. - Terminal: `barshelf dock use Work`, `barshelf dock next`, `barshelf dock list`. From 1222afeebe2ea0bf4ef89028d9b297c9cf3a5808 Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Thu, 8 Oct 2026 18:32:47 +0900 Subject: [PATCH 08/10] feat(dock): folder grids, recent apps, and a pressable BarShelf sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Folders open as a grid above the dock, like the Apple Dock's stacks: newest first by date added, subfolders open in place with a way back, files drag out. 'Open folders as ▸ List' keeps the menu. - Show recent apps: up to three apps used lately that are neither in the profile nor open. What was frontmost when BarShelf started does not count as used. - BarShelf window sidebar rows answer a press as well as a selection; accessibility clients that press (automation, Switch Control) could not change pages. Co-Authored-By: Claude Opus 5.5 (1M context) --- Localization/Localizable.xcstrings | 30 ++++ Sources/MenubucketApp/Dock/DockActions.swift | 34 ++++- .../MenubucketApp/Dock/DockFolderStack.swift | 131 ++++++++++++++++++ .../MenubucketApp/Dock/DockSettingsPage.swift | 6 + Sources/MenubucketApp/Dock/DockView.swift | 69 ++++++++- Sources/MenubucketApp/Hub/HubView.swift | 4 + Sources/MenubucketCore/Dock.swift | 17 +++ Tests/MenubucketAppTests/DockStoreTests.swift | 46 ++++++ .../DockConfigurationTests.swift | 4 + docs/DOCK.md | 9 +- 10 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 Sources/MenubucketApp/Dock/DockFolderStack.swift diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 170d185..29c60c8 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -5979,6 +5979,16 @@ } } }, + "Open folders as": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기 방식" + } + } + } + }, "Open in Finder": { "localizations": { "ko": { @@ -8192,6 +8202,16 @@ } } }, + "Show recent apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 앱 보기" + } + } + } + }, "Show the card": { "localizations": { "ko": { @@ -9575,6 +9595,16 @@ } } }, + "Up to three apps you used lately that aren't in the dock or open.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 없고 열려 있지도 않은, 최근에 쓴 앱을 세 개까지 보여 줍니다." + } + } + } + }, "Update": { "comment": "Button: install a newer version of a widget", "localizations": { diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift index baaf08f..cdcc4e1 100644 --- a/Sources/MenubucketApp/Dock/DockActions.swift +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -20,12 +20,20 @@ final class RunningApps: ObservableObject { /// Apps opened from the dock that have not finished launching; their /// icons bounce until they have, as on the Apple Dock. @Published private(set) var launching: Set = [] + /// Apps brought forward lately, most recent first, kept across launches + /// for the dock's recent-apps section. + @Published private(set) var recent: [String] = [] + private static let recentKey = "BarShelfDockRecentApps" + private static let recentKept = 12 + private let defaults: UserDefaults /// Longest a bounce lasts if an app never reports that it launched. static let launchTimeout: TimeInterval = 15 private var observers: [NSObjectProtocol] = [] - init() { + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + recent = defaults.stringArray(forKey: Self.recentKey) ?? [] let center = NSWorkspace.shared.notificationCenter for name in [ NSWorkspace.didLaunchApplicationNotification, @@ -50,7 +58,10 @@ final class RunningApps: ObservableObject { observers.forEach(NSWorkspace.shared.notificationCenter.removeObserver) } + private var hasLooked = false + func reload() { + defer { hasLooked = true } let running = NSWorkspace.shared.runningApplications .filter { $0.activationPolicy == .regular && !$0.isTerminated } .sorted { ($0.launchDate ?? .distantPast) < ($1.launchDate ?? .distantPast) } @@ -60,7 +71,26 @@ final class RunningApps: ObservableObject { } if running != apps { apps = running } let front = NSWorkspace.shared.frontmostApplication?.bundleURL.map { Self.key($0.path) } - if front != frontmostPath { frontmostPath = front } + if front != frontmostPath { + let isFirstLook = !hasLooked + frontmostPath = front + // Whatever is frontmost when BarShelf starts was not just used. + if !isFirstLook, let front, NSWorkspace.shared.frontmostApplication?.activationPolicy == .regular, + front != Bundle.main.bundleURL.standardizedFileURL.path { + noteUsed(front) + } + } + } + + /// Moves an app to the front of the recent list. + func noteUsed(_ path: String) { + let key = Self.key(path) + var list = recent.filter { $0 != key } + list.insert(key, at: 0) + list = Array(list.prefix(Self.recentKept)) + guard list != recent else { return } + recent = list + defaults.set(list, forKey: Self.recentKey) } /// Starts an icon bouncing, unless the app is already up. diff --git a/Sources/MenubucketApp/Dock/DockFolderStack.swift b/Sources/MenubucketApp/Dock/DockFolderStack.swift new file mode 100644 index 0000000..9d1d58a --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockFolderStack.swift @@ -0,0 +1,131 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// A folder opened from the dock as a grid of its contents, the way the Apple +/// Dock shows a stack: newest first by date added, subfolders open in place +/// with a way back, and files can be dragged out. +struct DockFolderStack: View { + let root: URL + let onClose: () -> Void + + @State private var trail: [URL] = [] + @State private var entries: [Entry] = [] + + /// Enough to find a recent download; the rest is a click away in Finder. + static let limit = 60 + + struct Entry: Identifiable, Equatable { + let url: URL + let name: String + let opensInPlace: Bool + var id: URL { url } + } + + private var folder: URL { trail.last ?? root } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if entries.isEmpty { + Text("Empty Folder") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 84), spacing: 8)], spacing: 10) { + ForEach(entries) { entry in cell(entry) } + } + .padding(12) + } + .frame(maxHeight: 380) + } + } + .frame(width: 452) + .onAppear { load() } + .onChange(of: trail) { _, _ in load() } + } + + private var header: some View { + HStack(spacing: Spacing.xs) { + if !trail.isEmpty { + Button { + trail.removeLast() + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + .help("Back") + .accessibilityLabel(Text("Back")) + } + Text(FileManager.default.displayName(atPath: folder.path)) + .font(.headline) + .lineLimit(1) + Spacer() + Button("Open in Finder") { + NSWorkspace.shared.open(folder) + onClose() + } + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func cell(_ entry: Entry) -> some View { + Button { + if entry.opensInPlace { + trail.append(entry.url) + } else { + NSWorkspace.shared.open(entry.url) + onClose() + } + } label: { + VStack(spacing: 4) { + Image(nsImage: DockActions.fileIcon(at: entry.url.path)) + .resizable() + .interpolation(.high) + .frame(width: 52, height: 52) + Text(entry.name) + .font(.caption) + .lineLimit(2) + .multilineTextAlignment(.center) + .truncationMode(.middle) + .frame(width: 80) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onDrag { NSItemProvider(object: entry.url as NSURL) } + .help(entry.name) + .accessibilityLabel(Text(entry.name)) + } + + private func load() { + entries = Self.entries(in: folder) + } + + /// The folder's visible contents, newest first by date added (then + /// modified), as many as `limit`. + static func entries(in folder: URL) -> [Entry] { + let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey, .addedToDirectoryDateKey, .contentModificationDateKey] + let urls = (try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] + )) ?? [] + let dated = urls.map { url -> (URL, Date, URLResourceValues?) in + let values = try? url.resourceValues(forKeys: Set(keys)) + return (url, values?.addedToDirectoryDate ?? values?.contentModificationDate ?? .distantPast, values) + } + return dated + .sorted { $0.1 > $1.1 } + .prefix(limit) + .map { url, _, values in + Entry( + url: url, + name: FileManager.default.displayName(atPath: url.path), + opensInPlace: values?.isDirectory == true && values?.isPackage != true + ) + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index 160105b..dcb0dd8 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -207,6 +207,12 @@ struct DockSettingsPage: View { Toggle("Animate opening apps", isOn: binding(\.animateOpening)) Toggle("Show indicators for open apps", isOn: binding(\.showIndicators)) Toggle("Show open apps that aren't in the dock", isOn: binding(\.showRunningApps)) + Toggle("Show recent apps", isOn: binding(\.showRecentApps)) + .help("Up to three apps you used lately that aren't in the dock or open.") + Picker("Open folders as", selection: binding(\.folderView)) { + Text("Grid").tag(DockConfiguration.FolderView.grid) + Text("List").tag(DockConfiguration.FolderView.list) + } Toggle("Show Trash", isOn: binding(\.showTrash)) } .disabled(!config.mode.showsDock) diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift index 5b985e1..2fd1a52 100644 --- a/Sources/MenubucketApp/Dock/DockView.swift +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -8,6 +8,8 @@ import UniformTypeIdentifiers enum DockTile: Identifiable, Equatable { case item(DockItem) case running(RunningApps.App) + /// Used lately, not in the profile and not open. + case recent(String) case divider(String) case trash @@ -15,6 +17,7 @@ enum DockTile: Identifiable, Equatable { switch self { case .item(let item): return item.id case .running(let app): return "running:\(app.path)" + case .recent(let path): return "recent:\(path)" case .divider(let id): return "divider:\(id)" case .trash: return "trash" } @@ -28,7 +31,7 @@ enum DockTile: Identifiable, Equatable { case .widget, .spacer, .separator: return false default: return true } - case .running, .trash: return true + case .running, .recent, .trash: return true case .divider: return false } } @@ -46,6 +49,8 @@ struct DockView: View { @State private var hoveredID: String? @State private var dropTargetID: String? + /// The folder whose grid is open above the dock. + @State private var openFolderID: String? static let dragPrefix = "barshelf-dock-item:" @@ -58,13 +63,22 @@ struct DockView: View { // MARK: Tiles var tiles: [DockTile] { - Self.tiles(for: store.configuration, running: running.apps) + Self.tiles(for: store.configuration, running: running.apps, recent: running.recent) } /// The profile's items, then running apps not among them, then the Trash. - static func tiles(for config: DockConfiguration, running: [RunningApps.App]) -> [DockTile] { + static func tiles( + for config: DockConfiguration, + running: [RunningApps.App], + recent: [String] = [], + exists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } + ) -> [DockTile] { let items = config.activeProfile.items var tiles = items.map(DockTile.item) + var shownApps = Set(items.compactMap { item -> String? in + if case .app(let path) = item.kind { return RunningApps.key(path) } + return nil + }) if config.showRunningApps { let pinned = Set(items.compactMap { item -> String? in if case .app(let path) = item.kind { return RunningApps.key(path) } @@ -76,6 +90,16 @@ struct DockView: View { tiles += extra.map(DockTile.running) } } + shownApps.formUnion(running.map(\.path)) + if config.showRecentApps { + let recents = recent + .filter { !shownApps.contains($0) && exists($0) } + .prefix(DockConfiguration.recentAppLimit) + if !recents.isEmpty { + if !tiles.isEmpty { tiles.append(.divider("recent")) } + tiles += recents.map { DockTile.recent($0) } + } + } if config.showTrash { if !tiles.isEmpty { tiles.append(.divider("trash")) } tiles.append(.trash) @@ -212,6 +236,17 @@ struct DockView: View { dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) }, menu: { runningAppMenu(app) } ) + case .recent(let path): + iconTile( + id: tile.id, + title: DockActions.fileDisplayName(at: path), + image: DockActions.fileIcon(at: path), + isRunning: false, scale: scale, + isLaunching: running.isLaunching(path: path), + action: { DockActions.openApp(path: path) }, + dropFiles: { urls in DockActions.open(urls, withAppAt: path) }, + menu: { recentAppMenu(path) } + ) case .divider: divider case .trash: @@ -255,13 +290,30 @@ struct DockView: View { id: item.id, title: DockActions.displayName(for: item), image: color == nil ? DockActions.icon(for: item) : nil, isRunning: false, scale: scale, - action: { Self.showFolderMenu(path: path) }, + action: { + if config.folderView == .grid { + openFolderID = openFolderID == item.id ? nil : item.id + } else { + Self.showFolderMenu(path: path) + } + }, dropFiles: nil, custom: color.map { color in AnyView(FolderBadge(color: color, label: label ?? DockActions.displayName(for: item))) }, menu: { itemMenu(item) } ) + .popover( + isPresented: Binding( + get: { openFolderID == item.id }, + set: { if !$0, openFolderID == item.id { openFolderID = nil } } + ), + arrowEdge: edge == .bottom ? .top : edge == .left ? .trailing : .leading + ) { + DockFolderStack(root: URL(fileURLWithPath: path, isDirectory: true)) { + openFolderID = nil + } + } case .link(let url, _): iconTile( id: item.id, title: DockActions.displayName(for: item), @@ -540,6 +592,15 @@ struct DockView: View { return DockMenuEntry.tidy(entries + [.divider] + barMenu) } + private func recentAppMenu(_ path: String) -> [DockMenuEntry] { + DockMenuEntry.tidy([ + .action(String(localized: "Open")) { DockActions.openApp(path: path) }, + .action(String(localized: "Keep in Dock")) { store.addItems([DockItem(kind: .app(path: path))]) }, + .action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }, + .divider, + ] + barMenu) + } + private var trashMenu: [DockMenuEntry] { DockMenuEntry.tidy([.action(String(localized: "Open")) { DockActions.openTrash() }, .divider] + barMenu) } diff --git a/Sources/MenubucketApp/Hub/HubView.swift b/Sources/MenubucketApp/Hub/HubView.swift index cd9660e..e98b34c 100644 --- a/Sources/MenubucketApp/Hub/HubView.swift +++ b/Sources/MenubucketApp/Hub/HubView.swift @@ -40,6 +40,10 @@ struct HubView: View { private func sidebarRow(_ tab: HubTab) -> some View { Label(tab.title, systemImage: tab.symbol) .tag(tab) + // A press as well as a selection, so accessibility clients that + // press rather than select (automation, Switch Control) can + // change pages; the row did not answer AXPress before. + .accessibilityAction { model.tab = tab } } private var sidebarHeader: some View { diff --git a/Sources/MenubucketCore/Dock.swift b/Sources/MenubucketCore/Dock.swift index 43f900c..0b51b15 100644 --- a/Sources/MenubucketCore/Dock.swift +++ b/Sources/MenubucketCore/Dock.swift @@ -60,6 +60,20 @@ public struct DockConfiguration: Codable, Equatable, Sendable { public var autoHideDelay: Double = DockConfiguration.defaultAutoHideDelay /// Which display the dock is on. public var display: Display = .main + /// How a folder opens: a grid of its contents, or a menu. + public var folderView: FolderView = .grid + /// Recently used apps that are neither in the profile nor open, after + /// the open ones — the Apple Dock's "suggested and recent apps". + public var showRecentApps: Bool = false + /// How many recent apps that section shows. + public static let recentAppLimit = 3 + + public enum FolderView: String, Codable, CaseIterable, Sendable { + /// A grid of icons above the dock, like the Apple Dock's stacks. + case grid + /// A menu, with subfolders as submenus. + case list + } public enum Display: String, Codable, CaseIterable, Sendable { /// The display with the menu bar. @@ -200,6 +214,7 @@ public struct DockConfiguration: Codable, Equatable, Sendable { case showRunningApps, showTrash, profiles, activeProfileID case profileHotkeysEnabled, appleDockLayouts, appleDockBackup case magnificationAmount, showIndicators, animateOpening, autoHideDelay, display + case folderView, showRecentApps } public init(from decoder: Decoder) throws { @@ -225,6 +240,8 @@ public struct DockConfiguration: Codable, Equatable, Sendable { animateOpening = (try? c.decodeIfPresent(Bool.self, forKey: .animateOpening)) ?? defaults.animateOpening autoHideDelay = (try? c.decodeIfPresent(Double.self, forKey: .autoHideDelay)) ?? defaults.autoHideDelay display = (try? c.decodeIfPresent(Display.self, forKey: .display)) ?? defaults.display + folderView = (try? c.decodeIfPresent(FolderView.self, forKey: .folderView)) ?? defaults.folderView + showRecentApps = (try? c.decodeIfPresent(Bool.self, forKey: .showRecentApps)) ?? defaults.showRecentApps normalize() } diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift index a990bc6..89a7b0f 100644 --- a/Tests/MenubucketAppTests/DockStoreTests.swift +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -151,6 +151,52 @@ final class DockStoreTests: XCTestCase { XCTAssertEqual(DockView.tiles(for: config, running: running).map(\.id), ["mail"]) } + func testRecentAppsSkipWhatIsAlreadyShown() { + var config = DockConfiguration(mode: .alongside) + config.profiles[0].items = [DockItem(id: "mail", kind: .app(path: "/Applications/Mail.app"))] + config.showTrash = false + config.showRecentApps = true + let running = [RunningApps.App(path: "/Applications/Notes.app", bundleID: "com.apple.Notes", processID: 1)] + let recent = ["/Applications/Mail.app", "/Applications/Notes.app", "/Applications/Gone.app", + "/Applications/A.app", "/Applications/B.app", "/Applications/C.app", "/Applications/D.app"] + let ids = DockView.tiles(for: config, running: running, recent: recent, exists: { !$0.contains("Gone") }).map(\.id) + XCTAssertEqual(ids, [ + "mail", "divider:running", "running:/Applications/Notes.app", "divider:recent", + "recent:/Applications/A.app", "recent:/Applications/B.app", "recent:/Applications/C.app", + ]) + } + + func testRecentListIsMostRecentFirstAndKept() throws { + let suite = "dock-recent-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let apps = RunningApps(defaults: defaults) + apps.noteUsed("/Applications/A.app") + apps.noteUsed("/Applications/B.app") + apps.noteUsed("/Applications/A.app") + // Real app switches may land in between; the order of these holds. + let a = try XCTUnwrap(apps.recent.firstIndex(of: "/Applications/A.app")) + let b = try XCTUnwrap(apps.recent.firstIndex(of: "/Applications/B.app")) + XCTAssertLessThan(a, b) + XCTAssertEqual(apps.recent.filter { $0 == "/Applications/A.app" }.count, 1) + XCTAssertEqual(RunningApps(defaults: defaults).recent, apps.recent) + } + + func testFolderStackIsNewestFirstAndOpensFoldersInPlace() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("stack-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + try FileManager.default.createDirectory(at: dir.appendingPathComponent("Sub"), withIntermediateDirectories: true) + let old = dir.appendingPathComponent("old.txt") + let new = dir.appendingPathComponent("new.txt") + try Data().write(to: old) + try Data().write(to: new) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSince1970: 0)], ofItemAtPath: old.path) + let entries = DockFolderStack.entries(in: dir) + XCTAssertEqual(Set(entries.map(\.name)), ["old.txt", "new.txt", "Sub"]) + XCTAssertTrue(entries.first { $0.name == "Sub" }!.opensInPlace) + XCTAssertFalse(entries.first { $0.name == "new.txt" }!.opensInPlace) + } + // MARK: URL func testDockDeepLinkRoutesToHook() { diff --git a/Tests/MenubucketCoreTests/DockConfigurationTests.swift b/Tests/MenubucketCoreTests/DockConfigurationTests.swift index e9ab6a9..5bcb373 100644 --- a/Tests/MenubucketCoreTests/DockConfigurationTests.swift +++ b/Tests/MenubucketCoreTests/DockConfigurationTests.swift @@ -73,6 +73,8 @@ final class DockConfigurationTests: XCTestCase { config.showIndicators = false config.animateOpening = false config.display = .pointer + config.folderView = .list + config.showRecentApps = true config.normalize() XCTAssertEqual(config.magnificationAmount, DockConfiguration.magnificationAmountRange.upperBound) XCTAssertEqual(config.autoHideDelay, 0) @@ -85,6 +87,8 @@ final class DockConfigurationTests: XCTestCase { XCTAssertTrue(old.showIndicators) XCTAssertTrue(old.animateOpening) XCTAssertEqual(old.display, .main) + XCTAssertEqual(old.folderView, .grid) + XCTAssertFalse(old.showRecentApps) XCTAssertEqual(old.magnificationAmount, DockConfiguration.defaultMagnificationAmount) } diff --git a/docs/DOCK.md b/docs/DOCK.md index e576789..2ee703c 100644 --- a/docs/DOCK.md +++ b/docs/DOCK.md @@ -34,9 +34,12 @@ screen edge. - **Apps**: click to open or bring forward. Drop files on an app to open them with it. A dot means the app is running. Apps that are open but not in the - profile come after a divider (optional). -- **Folders**: click for a menu of the folder's contents, with subfolders as - submenus. A folder can be a coloured tile with a letter or two. + profile come after a divider (optional), and up to three recently used + apps after those (**Show recent apps**). +- **Folders**: click for a grid of the folder's contents, newest first, as + the Apple Dock shows a stack: subfolders open in place, and files drag out. + **Open folders as ▸ List** shows a menu instead. A folder can be a coloured + tile with a letter or two. - **Files** and **links**: open on click. - **Shortcuts**: run on click, through the `shortcuts` tool. - **Widgets**: any BarShelf widget, live. A dock widget refreshes on its own From 7cc1fb5517cf1fce28d7090af9cbd6f5be02423c Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Thu, 8 Oct 2026 19:08:47 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat(dock):=20full-screen=20apps,=20and?= =?UTF-8?q?=20Automation=20keeps=20its=20=E2=8C=83=E2=8C=A5=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In a full-screen app the dock waits at the edge and slides out when the pointer rests there, like the Apple Dock; before, it never showed in a full-screen Space. A full-screen Space is told apart by an ordinary window covering the whole screen, menu bar area included (window bounds only, no permission). 'Show in full-screen apps'. - A small registry of the global keys BarShelf's own features claim. Automation claims its keys before registering, so a dock ⌃⌥ profile key no longer makes the whole Automation script fail to start; the dock leaves those keys to Automation (shown in settings) and takes them back when Automation stops. Two lines in AutomationEngine. Co-Authored-By: Claude Opus 5.5 (1M context) --- .context/nlc/barshelf_dock_folder_grid.json | 14 +++ .context/nlc/barshelf_dock_mode.json | 105 ++++++++++++++++++ Localization/Localizable.xcstrings | 40 +++++++ .../Automation/AutomationEngine.swift | 6 + Sources/MenubucketApp/Dock/DockHotkeys.swift | 49 +++++--- .../Dock/DockPanelController.swift | 74 +++++++++++- .../MenubucketApp/Dock/DockSettingsPage.swift | 9 +- Sources/MenubucketApp/InAppHotkeys.swift | 40 +++++++ Sources/MenubucketCore/Dock.swift | 6 +- Tests/MenubucketAppTests/DockStoreTests.swift | 42 +++++++ .../DockConfigurationTests.swift | 1 + docs/DOCK.md | 6 +- 12 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 .context/nlc/barshelf_dock_folder_grid.json create mode 100644 .context/nlc/barshelf_dock_mode.json create mode 100644 Sources/MenubucketApp/InAppHotkeys.swift diff --git a/.context/nlc/barshelf_dock_folder_grid.json b/.context/nlc/barshelf_dock_folder_grid.json new file mode 100644 index 0000000..d404c43 --- /dev/null +++ b/.context/nlc/barshelf_dock_folder_grid.json @@ -0,0 +1,14 @@ +{ + "name": "barshelf_dock_folder_grid", + "target": "com.barshelf.app", + "steps": [ + { "action": "click", + "selector": { "role": "Button", "desc": "Dl" }, + "intent_text": "독의 다운로드 폴더를 그리드로 열기", + "postcondition": { "contains": "Open in Finder" }, + "captures": [ { "name": "files", "many": true, "limit": 8, "optional": true, + "field": { "role": "Button", "ancestor": "Open in Finder" }, "attribute": "Description" } ], + "safety": { "effects": ["read"], "reversibility": "reversible", + "resource": { "object_type": "폴더 보기", "scope": "item" } } } + ] +} diff --git a/.context/nlc/barshelf_dock_mode.json b/.context/nlc/barshelf_dock_mode.json new file mode 100644 index 0000000..4f583ae --- /dev/null +++ b/.context/nlc/barshelf_dock_mode.json @@ -0,0 +1,105 @@ +{ + "name": "barshelf_dock_mode", + "target": "com.barshelf.app", + "params": { + "mode": "Alongside the Apple Dock" + }, + "steps": [ + { + "action": "menu", + "params": { + "path": [ + "BarShelf", + [ + "Settings…", + "설정…" + ] + ] + }, + "postcondition": { + "contains": "Shelf" + }, + "intent_text": "BarShelf 설정 창 열기", + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "설정 창", + "scope": "entry" + } + } + }, + { + "action": "click", + "selector": { + "role": "StaticText", + "value": "Dock", + "ancestor": "Sidebar" + }, + "click_ancestor": [ + "Row", + "Cell" + ], + "intent_text": "Dock 페이지로 이동", + "postcondition": { + "contains": "Instead of the Apple Dock" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "설정 페이지", + "scope": "entry" + } + } + }, + { + "action": "click", + "selector": { + "role": "RadioButton", + "desc": "{{mode}}" + }, + "intent_text": "BarShelf Dock 모드: {{mode}}", + "postcondition": { + "field": { + "role": "RadioButton", + "desc": "{{mode}}" + }, + "equals": "1", + "attribute": "Value" + }, + "safety": { + "effects": [ + "write_persistent" + ], + "reversibility": "reversible", + "resource": { + "object_type": "독 설정", + "scope": "item" + }, + "evidence": [ + { + "source": "user_intent", + "field": "asked", + "value": "실제로 테스트도해주세요 … 다른것들도 다 개선합시다" + } + ] + }, + "binding": { + "bundle_id": "com.barshelf.app", + "role": "RadioButton", + "operation": "click", + "resource_identity": "{{mode}}", + "resource_identity_from": { + "role": "RadioButton", + "desc": "{{mode}}", + "attribute": "Description" + } + } + } + ] +} \ No newline at end of file diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 29c60c8..b978b8f 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -166,6 +166,16 @@ } } }, + "%@ used by Automation": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: Automation에서 사용 중" + } + } + } + }, "%@ was found on your Mac": { "localizations": { "ko": { @@ -4117,6 +4127,16 @@ } } }, + "In a full-screen app the dock waits at the edge and slides out when the pointer rests there.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 화면 앱에서는 독이 가장자리에서 기다리다가 포인터를 대면 나옵니다." + } + } + } + }, "In the Menu Bar": { "localizations": { "ko": { @@ -8092,6 +8112,16 @@ } } }, + "Show in full-screen apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 화면 앱에서도 보기" + } + } + } + }, "Show in the menu bar": { "localizations": { "ko": { @@ -10289,6 +10319,16 @@ } } }, + "Your Automation script uses %@, so it goes there instead of switching profiles.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Automation 스크립트가 %@을(를) 쓰고 있어 프로필 전환 대신 그쪽으로 갑니다." + } + } + } + }, "Your installed copy was left untouched.": { "localizations": { "ko": { diff --git a/Sources/MenubucketApp/Automation/AutomationEngine.swift b/Sources/MenubucketApp/Automation/AutomationEngine.swift index 6dbbf13..f3c0107 100644 --- a/Sources/MenubucketApp/Automation/AutomationEngine.swift +++ b/Sources/MenubucketApp/Automation/AutomationEngine.swift @@ -73,6 +73,11 @@ final class AutomationEngine: AutomationRunning { guard !active else { return } guard AXIsProcessTrusted() else { throw AutomationFailure(String(localized: "Allow BarShelf in System Settings → Privacy & Security → Accessibility, then enable the extension again.")) } do { + // Take these keys from other BarShelf features (the dock's ⌃⌥ + // profile keys) before registering, rather than failing on them. + InAppHotkeys.shared.setAutomationKeys(Set(script.bindings.map { + InAppHotkeys.Key(keyCode: $0.combination.keyCode, modifiers: $0.combination.modifiers) + })) if !script.bindings.isEmpty { try registerHotkeys() } if script.remap != nil { try installTap() } active = true @@ -102,6 +107,7 @@ final class AutomationEngine: AutomationRunning { releaseHeldKeys() for key in hotkeys { UnregisterEventHotKey(key) } hotkeys.removeAll() + InAppHotkeys.shared.setAutomationKeys([]) if let handler { RemoveEventHandler(handler) } handler = nil } diff --git a/Sources/MenubucketApp/Dock/DockHotkeys.swift b/Sources/MenubucketApp/Dock/DockHotkeys.swift index 8563f3b..faa0ade 100644 --- a/Sources/MenubucketApp/Dock/DockHotkeys.swift +++ b/Sources/MenubucketApp/Dock/DockHotkeys.swift @@ -3,16 +3,19 @@ import Combine import Foundation import MenubucketCore -/// ⌃⌥1…9: switch to the first nine dock profiles (R15). Carbon hot keys, like -/// the popup shortcut, so no Accessibility permission is needed. Its own -/// signature keeps its presses apart from the popup shortcut's handler. -/// Which ⌃⌥ numbers another shortcut already owns — another app, or an -/// Automation shortcut — for settings to show. Positions are 1-based. +/// Which ⌃⌥ numbers the dock could not take, for settings to show. +/// Positions are 1-based. final class DockHotkeyStatus: ObservableObject { static let shared = DockHotkeyStatus() + /// Owned by another app. @Published fileprivate(set) var unavailable: Set = [] + /// Used by the Automation script, which wins. + @Published fileprivate(set) var heldByAutomation: Set = [] } +/// ⌃⌥1…9: switch to the first nine dock profiles (R15). Carbon hot keys, like +/// the popup shortcut, so no Accessibility permission is needed. Its own +/// signature keeps its presses apart from the popup shortcut's handler. final class DockHotkeys { static let signature: OSType = 0x4253_444B // 'BSDK' static let modifiers = UInt32(controlKey | optionKey) @@ -25,14 +28,23 @@ final class DockHotkeys { private let store: DockStore private var refs: [EventHotKeyRef] = [] private var handler: EventHandlerRef? - private var registeredCount = 0 + private var desiredCount = 0 private var unavailable: Set = [] { didSet { DockHotkeyStatus.shared.unavailable = unavailable } } + private var heldByAutomation: Set = [] { + didSet { DockHotkeyStatus.shared.heldByAutomation = heldByAutomation } + } private var cancellable: AnyCancellable? + private var automationObserver: UUID? init(store: DockStore) { self.store = store + // Automation claiming or releasing keys: register again around them. + automationObserver = InAppHotkeys.shared.observe { [weak self] in + guard let self else { return } + self.register(count: self.desiredCount) + } cancellable = store.$configuration .map { $0.profileHotkeysEnabled ? min($0.profiles.count, DockConfiguration.hotkeyProfileLimit) : 0 } .removeDuplicates() @@ -41,6 +53,7 @@ final class DockHotkeys { } deinit { + if let automationObserver { InAppHotkeys.shared.removeObserver(automationObserver) } unregisterAll() if let handler { RemoveEventHandler(handler) } } @@ -51,35 +64,41 @@ final class DockHotkeys { return "⌃⌥\(position)" } + /// Registers the first `count` ⌃⌥ numbers, leaving out the ones the + /// Automation script uses. Called again whenever either side changes; a + /// key another app held is retried each time. private func register(count: Int) { - // Same count and nothing failed last time: nothing to do. A failure - // is retried, since whatever held the key may have let it go. - guard count != registeredCount || !unavailable.isEmpty else { return } + desiredCount = count unregisterAll() guard count > 0 else { return } installHandlerIfNeeded() + let automation = InAppHotkeys.shared.automation var failed: Set = [] + var held: Set = [] for (index, keyCode) in Self.digitKeyCodes.prefix(count).enumerated() { + let position = index + 1 + if automation.contains(InAppHotkeys.Key(keyCode: keyCode, modifiers: Self.modifiers)) { + held.insert(position) + continue + } var ref: EventHotKeyRef? - let id = EventHotKeyID(signature: Self.signature, id: UInt32(index + 1)) + let id = EventHotKeyID(signature: Self.signature, id: UInt32(position)) if RegisterEventHotKey(keyCode, Self.modifiers, id, GetApplicationEventTarget(), 0, &ref) == noErr, let ref { refs.append(ref) } else { - failed.insert(index + 1) + failed.insert(position) } } - registeredCount = count unavailable = failed + heldByAutomation = held } - - private func unregisterAll() { refs.forEach { UnregisterEventHotKey($0) } refs.removeAll() - registeredCount = 0 unavailable = [] + heldByAutomation = [] } private func installHandlerIfNeeded() { diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index 2b6cb4b..ff25165 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -170,6 +170,12 @@ final class DockPanelController { .receive(on: RunLoop.main) .sink { [weak self] _ in self?.setNeedsMeasure() } .store(in: &cancellables) + for name in [NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.didActivateApplicationNotification] { + NSWorkspace.shared.notificationCenter.publisher(for: name) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.scheduleFullScreenCheck() } + .store(in: &cancellables) + } NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) .receive(on: RunLoop.main) // Displays coming and going change whether the pointer matters. @@ -198,13 +204,73 @@ final class DockPanelController { } else { removeMouseMonitors() } - if !config.autoHide { isRevealed = true } + panel.collectionBehavior = config.showInFullScreen + ? [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + : [.canJoinAllSpaces, .stationary, .ignoresCycle] + if !hidesAutomatically { isRevealed = true } setNeedsMeasure() placePanel(animated: false) if isRevealed { panel.orderFrontRegardless() } reportVisibleWidgets() } + // MARK: Full-screen Spaces + + /// The dock's display is showing a full-screen app. + private var inFullScreenSpace = false + + /// Auto-hide by choice, or because a full-screen app has the display. + private var hidesAutomatically: Bool { + config.autoHide || (inFullScreenSpace && config.showInFullScreen) + } + + /// Looks again after a Space switch or app change settles. + private func scheduleFullScreenCheck() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.checkFullScreen() + } + } + + private func checkFullScreen() { + guard config.mode.showsDock, let screen else { return } + let windows = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]]) ?? [] + let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height + let full = Self.isFullScreen( + windows: windows, + screenFrame: Self.windowServerFrame(of: screen.frame, primaryHeight: primaryHeight), + ownPID: ProcessInfo.processInfo.processIdentifier + ) + guard full != inFullScreenSpace else { return } + inFullScreenSpace = full + if full, config.showInFullScreen { + setRevealed(false) + } + configurationChanged() + } + + /// A screen frame in window-server coordinates (origin at the top left + /// of the main display, y down), the space window bounds come in. + static func windowServerFrame(of frame: NSRect, primaryHeight: CGFloat) -> CGRect { + CGRect(x: frame.minX, y: primaryHeight - frame.maxY, width: frame.width, height: frame.height) + } + + /// Whether another app has an ordinary window covering the whole screen, + /// menu bar area included. Only a full-screen window does that: zoomed + /// and tiled windows stop below the menu bar. Reads window bounds only, + /// which needs no permission. + static func isFullScreen(windows: [[String: Any]], screenFrame: CGRect, ownPID: pid_t) -> Bool { + windows.contains { window in + guard (window[kCGWindowLayer as String] as? Int) == 0, + (window[kCGWindowOwnerPID as String] as? pid_t) != ownPID, + let dict = window[kCGWindowBounds as String] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: dict) + else { return false } + return abs(bounds.minX - screenFrame.minX) < 1 && abs(bounds.minY - screenFrame.minY) < 1 + && abs(bounds.width - screenFrame.width) < 1 && abs(bounds.height - screenFrame.height) < 1 + } + } + private func hoverChanged(_ hovering: Bool) { isHovering = hovering if !hovering { setNeedsMeasure() } @@ -321,7 +387,7 @@ final class DockPanelController { /// stepping aside when the Apple Dock moves onto this display (which /// only happens with more than one display). private var needsPointerTracking: Bool { - config.autoHide || config.display == .pointer + hidesAutomatically || config.display == .pointer || (config.mode == .alongside && NSScreen.screens.count > 1) } @@ -349,7 +415,7 @@ final class DockPanelController { let point = NSEvent.mouseLocation followPointerIfNeeded(point) replaceIfAppleDockMoved() - guard config.autoHide, let screen else { return } + guard hidesAutomatically, let screen else { return } let atEdge = isAtEdge(point, of: screen) if !isRevealed { // Out only after the pointer has rested at the edge a moment, so @@ -417,7 +483,7 @@ final class DockPanelController { self.isAtEdge(NSEvent.mouseLocation, of: target) else { return } self.pointerDisplayID = targetID self.placePanel(animated: false) - if self.config.autoHide { self.setRevealed(true) } + if self.hidesAutomatically { self.setRevealed(true) } } moveWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + max(0.3, config.autoHideDelay), execute: work) diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index dcb0dd8..bec0666 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -204,6 +204,8 @@ struct DockSettingsPage: View { } } } + Toggle("Show in full-screen apps", isOn: binding(\.showInFullScreen)) + .help("In a full-screen app the dock waits at the edge and slides out when the pointer rests there.") Toggle("Animate opening apps", isOn: binding(\.animateOpening)) Toggle("Show indicators for open apps", isOn: binding(\.showIndicators)) Toggle("Show open apps that aren't in the dock", isOn: binding(\.showRunningApps)) @@ -271,7 +273,12 @@ struct DockSettingsPage: View { } Spacer() if config.profileHotkeysEnabled, let label = DockHotkeys.label(forPosition: position) { - if hotkeyStatus.unavailable.contains(position) { + if hotkeyStatus.heldByAutomation.contains(position) { + Label("\(label) used by Automation", systemImage: "keyboard") + .font(.caption) + .foregroundStyle(.secondary) + .help("Your Automation script uses \(label), so it goes there instead of switching profiles.") + } else if hotkeyStatus.unavailable.contains(position) { Label("\(label) is taken", systemImage: "exclamationmark.triangle.fill") .font(.caption) .foregroundStyle(.orange) diff --git a/Sources/MenubucketApp/InAppHotkeys.swift b/Sources/MenubucketApp/InAppHotkeys.swift new file mode 100644 index 0000000..2e9c5a5 --- /dev/null +++ b/Sources/MenubucketApp/InAppHotkeys.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Global shortcuts BarShelf's own features have claimed, so two of them do +/// not fight over one key. Carbon refuses a second registration of a +/// combination, and Automation stopped its whole script when one of its keys +/// was already a dock profile key. +/// +/// The user's Automation script wins: while it runs, the dock leaves its keys +/// alone and shows them as taken. +final class InAppHotkeys { + static let shared = InAppHotkeys() + + struct Key: Hashable { + let keyCode: UInt32 + let modifiers: UInt32 + } + + private(set) var automation: Set = [] + private var observers: [UUID: () -> Void] = [:] + + /// Automation is about to register these (or, with none, has stopped). + /// Observers run synchronously, so a key the dock held is free by the + /// time this returns. + func setAutomationKeys(_ keys: Set) { + guard keys != automation else { return } + automation = keys + observers.values.forEach { $0() } + } + + @discardableResult + func observe(_ handler: @escaping () -> Void) -> UUID { + let id = UUID() + observers[id] = handler + return id + } + + func removeObserver(_ id: UUID) { + observers.removeValue(forKey: id) + } +} diff --git a/Sources/MenubucketCore/Dock.swift b/Sources/MenubucketCore/Dock.swift index 0b51b15..6f2e5fa 100644 --- a/Sources/MenubucketCore/Dock.swift +++ b/Sources/MenubucketCore/Dock.swift @@ -65,6 +65,9 @@ public struct DockConfiguration: Codable, Equatable, Sendable { /// Recently used apps that are neither in the profile nor open, after /// the open ones — the Apple Dock's "suggested and recent apps". public var showRecentApps: Bool = false + /// In a full-screen app the dock waits at the edge, as the Apple Dock + /// does, instead of staying out of the full-screen Space altogether. + public var showInFullScreen: Bool = true /// How many recent apps that section shows. public static let recentAppLimit = 3 @@ -214,7 +217,7 @@ public struct DockConfiguration: Codable, Equatable, Sendable { case showRunningApps, showTrash, profiles, activeProfileID case profileHotkeysEnabled, appleDockLayouts, appleDockBackup case magnificationAmount, showIndicators, animateOpening, autoHideDelay, display - case folderView, showRecentApps + case folderView, showRecentApps, showInFullScreen } public init(from decoder: Decoder) throws { @@ -242,6 +245,7 @@ public struct DockConfiguration: Codable, Equatable, Sendable { display = (try? c.decodeIfPresent(Display.self, forKey: .display)) ?? defaults.display folderView = (try? c.decodeIfPresent(FolderView.self, forKey: .folderView)) ?? defaults.folderView showRecentApps = (try? c.decodeIfPresent(Bool.self, forKey: .showRecentApps)) ?? defaults.showRecentApps + showInFullScreen = (try? c.decodeIfPresent(Bool.self, forKey: .showInFullScreen)) ?? defaults.showInFullScreen normalize() } diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift index 89a7b0f..c6fc28b 100644 --- a/Tests/MenubucketAppTests/DockStoreTests.swift +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -197,6 +197,48 @@ final class DockStoreTests: XCTestCase { XCTAssertFalse(entries.first { $0.name == "new.txt" }!.opensInPlace) } + // MARK: Full screen + + func testFullScreenMeansAWindowCoveringTheWholeScreen() { + let main = DockPanelController.windowServerFrame(of: NSRect(x: 0, y: 0, width: 2560, height: 1440), primaryHeight: 1440) + let below = DockPanelController.windowServerFrame(of: NSRect(x: 0, y: -1440, width: 2560, height: 1440), primaryHeight: 1440) + XCTAssertEqual(below, CGRect(x: 0, y: 1440, width: 2560, height: 1440)) + func window(_ rect: CGRect, layer: Int = 0, pid: pid_t = 42) -> [String: Any] { + [kCGWindowLayer as String: layer, kCGWindowOwnerPID as String: pid, + kCGWindowBounds as String: rect.dictionaryRepresentation] + } + // A zoomed window stops below the menu bar. + XCTAssertFalse(DockPanelController.isFullScreen( + windows: [window(CGRect(x: 0, y: 30, width: 2560, height: 1410))], screenFrame: main, ownPID: 1)) + XCTAssertTrue(DockPanelController.isFullScreen( + windows: [window(main)], screenFrame: main, ownPID: 1)) + // Not on this display, not an ordinary window, or our own: no. + XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(below)], screenFrame: main, ownPID: 1)) + XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(main, layer: 25)], screenFrame: main, ownPID: 1)) + XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(main, pid: 1)], screenFrame: main, ownPID: 1)) + } + + // MARK: Hotkeys + + /// The Automation script wins a ⌃⌥ number over the dock, and gives it + /// back when it stops. + func testAutomationKeysAreLeftToAutomation() { + let store = makeStore() + _ = store.addProfile(named: "Work") + store.update { $0.profileHotkeysEnabled = true } + let hotkeys = DockHotkeys(store: store) + let expectation = expectation(description: "registered") + DispatchQueue.main.async { expectation.fulfill() } + wait(for: [expectation], timeout: 2) + + let one = InAppHotkeys.Key(keyCode: DockHotkeys.digitKeyCodes[0], modifiers: DockHotkeys.modifiers) + InAppHotkeys.shared.setAutomationKeys([one]) + XCTAssertEqual(DockHotkeyStatus.shared.heldByAutomation, [1]) + InAppHotkeys.shared.setAutomationKeys([]) + XCTAssertEqual(DockHotkeyStatus.shared.heldByAutomation, []) + _ = hotkeys + } + // MARK: URL func testDockDeepLinkRoutesToHook() { diff --git a/Tests/MenubucketCoreTests/DockConfigurationTests.swift b/Tests/MenubucketCoreTests/DockConfigurationTests.swift index 5bcb373..3dcaa96 100644 --- a/Tests/MenubucketCoreTests/DockConfigurationTests.swift +++ b/Tests/MenubucketCoreTests/DockConfigurationTests.swift @@ -89,6 +89,7 @@ final class DockConfigurationTests: XCTestCase { XCTAssertEqual(old.display, .main) XCTAssertEqual(old.folderView, .grid) XCTAssertFalse(old.showRecentApps) + XCTAssertTrue(old.showInFullScreen) XCTAssertEqual(old.magnificationAmount, DockConfiguration.defaultMagnificationAmount) } diff --git a/docs/DOCK.md b/docs/DOCK.md index 2ee703c..4bf4d8f 100644 --- a/docs/DOCK.md +++ b/docs/DOCK.md @@ -24,6 +24,9 @@ barshelf dock restore-apple-dock With BarShelf running, this asks the app to put the Apple Dock back and leave "Instead of the Apple Dock"; otherwise it restores the saved settings itself. +In a full-screen app the dock waits at the edge and slides out when the +pointer rests there, as the Apple Dock does (**Show in full-screen apps**). + macOS has no public way to make other apps' windows stay clear of a dock that isn't Apple's. Windows can go under the BarShelf Dock, as they do under an auto-hidden Apple Dock. Turn on **Automatically hide and show the dock** if @@ -76,7 +79,8 @@ Each profile has its own items. It can also carry: Ways to switch: - **⌃⌥1–9** for the first nine profiles (turn on in **Switching**). A number - another app or an Automation shortcut already uses is marked as taken. + another app already uses is marked as taken; one your Automation script uses + stays with Automation, and comes back to the dock when Automation stops. - A **two-finger sideways swipe** on the dock, or **⌘-scroll** over it. - **BarShelf menu ▸ Dock**, or right-click the dock. - A link: `barshelf://dock?profile=Work` (name, id, or number; the link From 51866d8b57ae24a68578bd492ac0394e968aeddf Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Fri, 9 Oct 2026 18:54:39 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix(dock):=20from=20live=20testing=20?= =?UTF-8?q?=E2=80=94=20notched=20full=20screen,=20Apple=20Dock=20size,=20f?= =?UTF-8?q?older=20grid=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by driving the installed build (nlc, a full-screen test window): - On a display with a camera housing a full-screen window starts below it (y=38), so 'covers the menu bar area' never matched and the dock stayed over full-screen apps. The full-screen test also accepts the screen minus its top safe-area inset; zoomed windows still do not reach it. - Same size as the Apple Dock (on by default): the size follows the Apple Dock's, which can change without notice (it went 36 → 29 pt on the laptop display). Range widened to the Apple Dock's 16–128 pt. - Folder grids: a fixed four-column grid with a height from its rows (the scroll view had none, so the popover showed one row), and contents read before it opens (it was placed for 'Empty Folder' and then grew down over the dock). - Recent apps are app bundles only; the test window's bare executable had become a recent app. Keeps the nlc flows used (settings ▸ Dock ▸ mode, folder grid) under .context/nlc. Co-Authored-By: Claude Opus 5.5 (1M context) --- .context/nlc/barshelf_dock_folder_close.json | 27 +++++++ .context/nlc/barshelf_dock_folder_into.json | 74 +++++++++++++++++++ .context/nlc/barshelf_dock_mode.json | 17 +---- Localization/Localizable.xcstrings | 11 +++ Sources/MenubucketApp/Dock/DockActions.swift | 3 + .../MenubucketApp/Dock/DockFolderStack.swift | 34 +++++++-- .../Dock/DockPanelController.swift | 31 ++++++-- .../MenubucketApp/Dock/DockSettingsPage.swift | 33 +++++---- Sources/MenubucketApp/Dock/DockStore.swift | 9 +++ Sources/MenubucketCore/Dock.swift | 9 ++- Tests/MenubucketAppTests/DockStoreTests.swift | 26 +++++++ docs/DOCK.md | 3 +- 12 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 .context/nlc/barshelf_dock_folder_close.json create mode 100644 .context/nlc/barshelf_dock_folder_into.json diff --git a/.context/nlc/barshelf_dock_folder_close.json b/.context/nlc/barshelf_dock_folder_close.json new file mode 100644 index 0000000..69fd8ff --- /dev/null +++ b/.context/nlc/barshelf_dock_folder_close.json @@ -0,0 +1,27 @@ +{ + "name": "barshelf_dock_folder_close", + "target": "com.barshelf.app", + "steps": [ + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Dl" + }, + "intent_text": "그리드 닫기", + "postcondition": { + "absent": "Open in Finder" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + } + ] +} \ No newline at end of file diff --git a/.context/nlc/barshelf_dock_folder_into.json b/.context/nlc/barshelf_dock_folder_into.json new file mode 100644 index 0000000..c29af7c --- /dev/null +++ b/.context/nlc/barshelf_dock_folder_into.json @@ -0,0 +1,74 @@ +{ + "name": "barshelf_dock_folder_into", + "target": "com.barshelf.app", + "params": { + "folder": "output" + }, + "steps": [ + { + "action": "click", + "selector": { + "role": "Button", + "desc": "{{folder}}", + "ancestor": "Dl" + }, + "intent_text": "그리드에서 {{folder}} 폴더로 들어가기", + "postcondition": { + "contains": "Back" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + }, + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Back", + "ancestor": "Dl" + }, + "intent_text": "상위 폴더로 돌아가기", + "postcondition": { + "contains": "{{folder}}" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + }, + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Dl" + }, + "intent_text": "그리드 닫기", + "postcondition": { + "absent": "Open in Finder" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + } + ] +} \ No newline at end of file diff --git a/.context/nlc/barshelf_dock_mode.json b/.context/nlc/barshelf_dock_mode.json index 4f583ae..82acf5d 100644 --- a/.context/nlc/barshelf_dock_mode.json +++ b/.context/nlc/barshelf_dock_mode.json @@ -34,14 +34,10 @@ { "action": "click", "selector": { - "role": "StaticText", - "value": "Dock", + "role": "Button", + "desc": "Dock", "ancestor": "Sidebar" }, - "click_ancestor": [ - "Row", - "Cell" - ], "intent_text": "Dock 페이지로 이동", "postcondition": { "contains": "Instead of the Apple Dock" @@ -92,13 +88,8 @@ "binding": { "bundle_id": "com.barshelf.app", "role": "RadioButton", - "operation": "click", - "resource_identity": "{{mode}}", - "resource_identity_from": { - "role": "RadioButton", - "desc": "{{mode}}", - "attribute": "Description" - } + "desc": "{{mode}}", + "operation": "click" } } ] diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index b978b8f..6677ec8 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -5043,6 +5043,7 @@ } }, "Match Apple Dock": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -7560,6 +7561,16 @@ } } }, + "Same size as the Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock과 같은 크기" + } + } + } + }, "Save & Reload": { "localizations": { "ko": { diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift index cdcc4e1..5213c48 100644 --- a/Sources/MenubucketApp/Dock/DockActions.swift +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -85,6 +85,9 @@ final class RunningApps: ObservableObject { /// Moves an app to the front of the recent list. func noteUsed(_ path: String) { let key = Self.key(path) + // Only app bundles: a bare executable with a window (a test tool, a + // script) has no icon or place in a dock. + guard key.hasSuffix(".app") else { return } var list = recent.filter { $0 != key } list.insert(key, at: 0) list = Array(list.prefix(Self.recentKept)) diff --git a/Sources/MenubucketApp/Dock/DockFolderStack.swift b/Sources/MenubucketApp/Dock/DockFolderStack.swift index 9d1d58a..80d4ef9 100644 --- a/Sources/MenubucketApp/Dock/DockFolderStack.swift +++ b/Sources/MenubucketApp/Dock/DockFolderStack.swift @@ -10,10 +10,29 @@ struct DockFolderStack: View { let onClose: () -> Void @State private var trail: [URL] = [] - @State private var entries: [Entry] = [] + @State private var entries: [Entry] + + init(root: URL, onClose: @escaping () -> Void) { + self.root = root + self.onClose = onClose + // Read before the popover opens: it is placed for its first size, and + // contents arriving later made it grow down over the dock. + _entries = State(initialValue: Self.entries(in: root)) + } /// Enough to find a recent download; the rest is a click away in Finder. static let limit = 60 + static let columns = 4 + static let cellWidth: CGFloat = 96 + static let rowHeight: CGFloat = 92 + static let rowSpacing: CGFloat = 10 + + /// Every row when they fit, else four and a half, so it reads as more. + static func gridHeight(count: Int) -> CGFloat { + let rows = max(1, (count + columns - 1) / columns) + let shown = min(CGFloat(rows), 4.5) + return shown * rowHeight + (shown.rounded(.up) - 1) * rowSpacing + 24 + } struct Entry: Identifiable, Equatable { let url: URL @@ -34,16 +53,19 @@ struct DockFolderStack: View { .frame(maxWidth: .infinity, minHeight: 120) } else { ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 84), spacing: 8)], spacing: 10) { + LazyVGrid(columns: Array(repeating: GridItem(.fixed(Self.cellWidth), spacing: 8), count: Self.columns), + spacing: Self.rowSpacing) { ForEach(entries) { entry in cell(entry) } } .padding(12) } - .frame(maxHeight: 380) + // A scroll view has no height of its own, and the popover + // took the smallest it could: one row. Rows decide it, up to + // a screenful. + .frame(height: Self.gridHeight(count: entries.count)) } } - .frame(width: 452) - .onAppear { load() } + .frame(width: CGFloat(Self.columns) * Self.cellWidth + CGFloat(Self.columns - 1) * 8 + 24) .onChange(of: trail) { _, _ in load() } } @@ -92,7 +114,7 @@ struct DockFolderStack: View { .lineLimit(2) .multilineTextAlignment(.center) .truncationMode(.middle) - .frame(width: 80) + .frame(width: Self.cellWidth - 8) } .contentShape(Rectangle()) } diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift index ff25165..24d2611 100644 --- a/Sources/MenubucketApp/Dock/DockPanelController.swift +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -182,6 +182,7 @@ final class DockPanelController { .sink { [weak self] _ in self?.configurationChanged() } .store(in: &cancellables) configurationChanged() + store.syncSizeWithAppleDock() } deinit { @@ -228,6 +229,7 @@ final class DockPanelController { private func scheduleFullScreenCheck() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.checkFullScreen() + self?.store.syncSizeWithAppleDock() } } @@ -239,6 +241,7 @@ final class DockPanelController { let full = Self.isFullScreen( windows: windows, screenFrame: Self.windowServerFrame(of: screen.frame, primaryHeight: primaryHeight), + topInset: screen.safeAreaInsets.top, ownPID: ProcessInfo.processInfo.processIdentifier ) guard full != inFullScreenSpace else { return } @@ -256,18 +259,32 @@ final class DockPanelController { } /// Whether another app has an ordinary window covering the whole screen, - /// menu bar area included. Only a full-screen window does that: zoomed - /// and tiled windows stop below the menu bar. Reads window bounds only, - /// which needs no permission. - static func isFullScreen(windows: [[String: Any]], screenFrame: CGRect, ownPID: pid_t) -> Bool { - windows.contains { window in + /// menu bar area included, which only a full-screen window does: zoomed + /// and tiled windows stop below the menu bar. On a display with a camera + /// housing a full-screen window starts below it instead (`topInset`, the + /// screen's top safe-area inset), which a zoomed window still does not + /// reach, since the menu bar there is taller than the housing area it + /// shares. Reads window bounds only, which needs no permission. + static func isFullScreen( + windows: [[String: Any]], screenFrame: CGRect, topInset: CGFloat = 0, ownPID: pid_t + ) -> Bool { + var targets = [screenFrame] + if topInset > 0 { + targets.append(CGRect( + x: screenFrame.minX, y: screenFrame.minY + topInset, + width: screenFrame.width, height: screenFrame.height - topInset + )) + } + return windows.contains { window in guard (window[kCGWindowLayer as String] as? Int) == 0, (window[kCGWindowOwnerPID as String] as? pid_t) != ownPID, let dict = window[kCGWindowBounds as String] as? NSDictionary, let bounds = CGRect(dictionaryRepresentation: dict) else { return false } - return abs(bounds.minX - screenFrame.minX) < 1 && abs(bounds.minY - screenFrame.minY) < 1 - && abs(bounds.width - screenFrame.width) < 1 && abs(bounds.height - screenFrame.height) < 1 + return targets.contains { target in + abs(bounds.minX - target.minX) < 1 && abs(bounds.minY - target.minY) < 1 + && abs(bounds.width - target.width) < 1 && abs(bounds.height - target.height) < 1 + } } } diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index bec0666..4b61c1a 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -141,22 +141,27 @@ struct DockSettingsPage: View { Text("Right").tag(DockConfiguration.Edge.right) } .pickerStyle(.segmented) + Toggle("Same size as the Apple Dock", isOn: Binding( + get: { config.matchAppleDockSize }, + set: { on in + store.update { $0.matchAppleDockSize = on } + if on { store.syncSizeWithAppleDock() } + } + )) LabeledContent("Size") { - HStack { - Slider(value: binding(\.tileSize), in: DockConfiguration.tileSizeRange) { - Text("Size") - } minimumValueLabel: { - Text("Small").font(.caption) - } maximumValueLabel: { - Text("Large").font(.caption) - } - .labelsHidden() - .frame(maxWidth: 260) - if let apple = store.appleDock.tileSize, abs(apple - config.tileSize) >= 1 { - Button("Match Apple Dock") { store.update { $0.tileSize = apple } } - .controlSize(.small) - } + // Moving the slider is choosing a size of one's own. + Slider(value: Binding( + get: { config.tileSize }, + set: { size in store.update { $0.tileSize = size; $0.matchAppleDockSize = false } } + ), in: DockConfiguration.tileSizeRange) { + Text("Size") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) } + .labelsHidden() + .frame(maxWidth: 260) } if config.style == .shelf { LabeledContent("Widget size") { diff --git a/Sources/MenubucketApp/Dock/DockStore.swift b/Sources/MenubucketApp/Dock/DockStore.swift index 7a840e1..f2f396a 100644 --- a/Sources/MenubucketApp/Dock/DockStore.swift +++ b/Sources/MenubucketApp/Dock/DockStore.swift @@ -220,6 +220,15 @@ final class DockStore: ObservableObject { return others.isEmpty ? apps : apps + [DockItem(kind: .separator)] + others } + /// Follows the Apple Dock's icon size when that is turned on. Its size + /// changes in System Settings without telling anyone, so this is called + /// at launch and whenever apps or Spaces change. + func syncSizeWithAppleDock() { + guard configuration.matchAppleDockSize, let size = appleDock.tileSize, + abs(size - configuration.tileSize) >= 0.5 else { return } + update { $0.tileSize = size } + } + // MARK: Hiding the Apple Dock (`replace`) private func hideAppleDock() { diff --git a/Sources/MenubucketCore/Dock.swift b/Sources/MenubucketCore/Dock.swift index 6f2e5fa..e12320b 100644 --- a/Sources/MenubucketCore/Dock.swift +++ b/Sources/MenubucketCore/Dock.swift @@ -65,6 +65,8 @@ public struct DockConfiguration: Codable, Equatable, Sendable { /// Recently used apps that are neither in the profile nor open, after /// the open ones — the Apple Dock's "suggested and recent apps". public var showRecentApps: Bool = false + /// Icon size follows the Apple Dock's, even as it changes. + public var matchAppleDockSize: Bool = true /// In a full-screen app the dock waits at the edge, as the Apple Dock /// does, instead of staying out of the full-screen Space altogether. public var showInFullScreen: Bool = true @@ -98,7 +100,8 @@ public struct DockConfiguration: Codable, Equatable, Sendable { /// Dock. public var appleDockBackup: AppleDockVisibility? - public static let tileSizeRange: ClosedRange = 32...96 + /// The Apple Dock's own range. + public static let tileSizeRange: ClosedRange = 16...128 public static let widgetSizeRange: ClosedRange = 72...220 public static let defaultTileSize: Double = 52 public static let defaultWidgetSize: Double = 110 @@ -217,7 +220,7 @@ public struct DockConfiguration: Codable, Equatable, Sendable { case showRunningApps, showTrash, profiles, activeProfileID case profileHotkeysEnabled, appleDockLayouts, appleDockBackup case magnificationAmount, showIndicators, animateOpening, autoHideDelay, display - case folderView, showRecentApps, showInFullScreen + case folderView, showRecentApps, showInFullScreen, matchAppleDockSize } public init(from decoder: Decoder) throws { @@ -246,6 +249,8 @@ public struct DockConfiguration: Codable, Equatable, Sendable { folderView = (try? c.decodeIfPresent(FolderView.self, forKey: .folderView)) ?? defaults.folderView showRecentApps = (try? c.decodeIfPresent(Bool.self, forKey: .showRecentApps)) ?? defaults.showRecentApps showInFullScreen = (try? c.decodeIfPresent(Bool.self, forKey: .showInFullScreen)) ?? defaults.showInFullScreen + matchAppleDockSize = (try? c.decodeIfPresent(Bool.self, forKey: .matchAppleDockSize)) + ?? defaults.matchAppleDockSize normalize() } diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift index c6fc28b..9e32686 100644 --- a/Tests/MenubucketAppTests/DockStoreTests.swift +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -46,6 +46,20 @@ final class DockStoreTests: XCTestCase { XCTAssertEqual(restarts, 2) } + func testSizeFollowsTheAppleDockUntilChosen() { + defaults.values["tilesize"] = 29.0 + let store = makeStore() + store.syncSizeWithAppleDock() + XCTAssertEqual(store.configuration.tileSize, 29) + defaults.values["tilesize"] = 48.0 + store.syncSizeWithAppleDock() + XCTAssertEqual(store.configuration.tileSize, 48) + store.update { $0.matchAppleDockSize = false; $0.tileSize = 60 } + defaults.values["tilesize"] = 20.0 + store.syncSizeWithAppleDock() + XCTAssertEqual(store.configuration.tileSize, 60) + } + func testOtherEditsDoNotTouchTheAppleDock() { let store = makeStore() store.update { $0.mode = .alongside } @@ -180,6 +194,8 @@ final class DockStoreTests: XCTestCase { XCTAssertLessThan(a, b) XCTAssertEqual(apps.recent.filter { $0 == "/Applications/A.app" }.count, 1) XCTAssertEqual(RunningApps(defaults: defaults).recent, apps.recent) + apps.noteUsed("/tmp/scratch/fstest") + XCTAssertFalse(apps.recent.contains("/tmp/scratch/fstest"), "bare executables are not apps") } func testFolderStackIsNewestFirstAndOpensFoldersInPlace() throws { @@ -216,6 +232,16 @@ final class DockStoreTests: XCTestCase { XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(below)], screenFrame: main, ownPID: 1)) XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(main, layer: 25)], screenFrame: main, ownPID: 1)) XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(main, pid: 1)], screenFrame: main, ownPID: 1)) + + // A notched laptop: full screen starts below the camera housing (38 pt), + // and a zoomed window starts below the taller menu bar (49 pt here). + let laptop = CGRect(x: 0, y: 0, width: 1710, height: 1112) + XCTAssertTrue(DockPanelController.isFullScreen( + windows: [window(CGRect(x: 0, y: 38, width: 1710, height: 1074))], + screenFrame: laptop, topInset: 38, ownPID: 1)) + XCTAssertFalse(DockPanelController.isFullScreen( + windows: [window(CGRect(x: 0, y: 49, width: 1710, height: 1014))], + screenFrame: laptop, topInset: 38, ownPID: 1)) } // MARK: Hotkeys diff --git a/docs/DOCK.md b/docs/DOCK.md index 4bf4d8f..8a9298a 100644 --- a/docs/DOCK.md +++ b/docs/DOCK.md @@ -54,7 +54,8 @@ Drag apps, folders, files, or web links onto the dock to add them. Drag an item onto another to move it. Right-click any item for its menu, including **Remove from Dock**. -The Apple Dock's own settings are there too: size, magnification and how +The Apple Dock's own settings are there too: size (by default the same as +the Apple Dock's, following it when it changes), magnification and how much, which display (the main one, or the one the pointer rests at the edge of), auto-hide and how long the pointer waits at the edge, opening animation, and indicators for open apps.