diff --git a/CHANGELOG.md b/CHANGELOG.md index 298d7d3..0cb8d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ ## [Не выпущено] +### Исправлено + +- Панель на двух мониторах больше не уезжает к чужому краю. Строка меню в + полноэкранном режиме прячется, уводя окно значка на 30 точек вверх — за + верхнюю кромку своего экрана, а если сверху вплотную стоит второй монитор, то + и на его территорию. Панель считалась от границ соседа и повисала у его + нижнего края, оторванная от строки меню на пол-экрана. Теперь экран + определяется по верхней кромке, до которой значку ближе всего, и спрятанная + строка меню этого не сбивает. + ## [0.1.11] — 2026-08-16 ### Добавлено diff --git a/README.md b/README.md index 065f67f..35401d9 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg | [docs/USAGE.md](docs/USAGE.md) | руководство: доступ, панель, расчёт плана, все настройки и ключи конфига, командная строка | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | карта кода: кто за что отвечает, потоки данных, инварианты, рецепты правок | | [docs/API.md](docs/API.md) | официальный источник: схема ответа, токен, дисциплина запросов | -| [docs/SPACES.md](docs/SPACES.md) | панель и рабочие столы: почему «не открывается по щелчку», измерения и пробы для разбора | +| [docs/SPACES.md](docs/SPACES.md) | панель, рабочие столы и мониторы: почему «не открывается по щелчку» и почему вставала у чужого края, измерения и пробы для разбора | | [docs/PLAN.md](docs/PLAN.md) | план работ, модель данных, разбор рисков и отвергнутых вариантов | | [docs/ROADMAP.md](docs/ROADMAP.md) | чего не хватает и что делать дальше | diff --git a/Sources/ClaudeWeekApp/DropdownPanel.swift b/Sources/ClaudeWeekApp/DropdownPanel.swift index 82fd7d9..c342cc8 100644 --- a/Sources/ClaudeWeekApp/DropdownPanel.swift +++ b/Sources/ClaudeWeekApp/DropdownPanel.swift @@ -246,14 +246,45 @@ final class DropdownPanel { x: anchor.frame.midX - size.width / 2, y: anchor.frame.minY - size.height ) - if let screen = (anchor.screen ?? NSScreen.main)?.frame { + if let screen = screen(for: anchor)?.frame { let margin = Theme.panelScreenMargin origin.x = min(max(origin.x, screen.minX + margin), screen.maxX - size.width - margin) + // Верхняя кромка — не выше своего экрана. Строка меню в + // полноэкранном режиме прячется вместе со значком за верхний край, + // и панель, приклеенная к значку, ушла бы туда же — на соседний + // монитор, если он стоит сверху. + origin.y = min(origin.y, screen.maxY - size.height) origin.y = max(origin.y, screen.minY + margin) } return NSRect(origin: origin, size: size) } + /// Экран, на строке меню которого стоит значок. + /// + /// `anchor.screen` на этот вопрос отвечает неверно. В полноэкранном режиме + /// строка меню прячется, и её окно вместе со значком уезжает вверх ровно на + /// свою высоту — за верхнюю кромку своего экрана. Стоит сверху вплотную + /// второй монитор, и значок оказывается уже на его территории: `anchor.screen` + /// честно отвечает «второй», панель считается от чужих границ и прижимается + /// к его нижнему краю. Так она и висела посреди верхнего монитора, оторванная + /// от строки меню. `NSScreen.main` на замену не годится тем более: это экран + /// с клавиатурным фокусом, то есть чужого окна, а не нашего значка. + /// + /// Признак надёжнее: строка меню всегда прижата к верхней кромке своего + /// экрана. Значит значок принадлежит тому из накрывающих его по горизонтали + /// экранов, чья верхняя кромка к нему ближе, — и спрятанная строка меню + /// с её сдвигом на 30 точек этого не меняет. + private func screen(for anchor: NSWindow) -> NSScreen? { + let icon = anchor.frame + let covering = NSScreen.screens.filter { + $0.frame.minX <= icon.midX && icon.midX <= $0.frame.maxX + } + // Значок, не влезший в строку меню, лежит левее всех экранов — по + // горизонтали его не накрывает никто, и выбирать приходится из всех. + return (covering.isEmpty ? NSScreen.screens : covering) + .min { abs($0.frame.maxY - icon.maxY) < abs($1.frame.maxY - icon.maxY) } + } + /// Содержимое меняет высоту на ходу: приходят данные, появляется строка /// сессии или прогноза, раскрывается недельный ряд. Растём вниз — верхняя /// кромка приклеена к строке меню и дёргаться не должна. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 701ca23..9a95aa0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -140,7 +140,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `main.swift` | разбор аргументов командной строки, режимы `--json`, `--calibrate`, `--screenshot`, `--icon`, `--update`; запуск `NSApplication` в режиме `.accessory`. Незнакомый флаг — ошибка с кодом 2 (`CLI.isKnown`), а не молчаливый запуск иконки | | `StatusItemController.swift` | **склейка всего**: пункт строки меню, таймеры, сон/пробуждение, смена таймзоны, перечитывание конфига, открытие панели и настроек | | `PanelModel.swift` | состояние для SwiftUI: снимок, статус, текущий момент, производные подписи | -| `DropdownPanel.swift` | окно панели: форма как у системного меню, материал, обводка, позиция под строкой меню, закрытие по клику мимо, закрепление на время настроек | +| `DropdownPanel.swift` | окно панели: форма как у системного меню, материал, обводка, позиция под строкой меню своего монитора, закрытие по клику мимо, закрепление на время настроек | | `PopoverView.swift` | вёрстка панели: заголовок, сессия, ряд дней (семь строк или одни текущие сутки — `appearance.panelLayout`), футер | | `DayBar.swift` | одна двухцветная полоса и строка дня; в компактном виде строка ещё и раскрывает неделю кликом (`DayRowTap`), а цифры справа открывают разбивку по моделям (`valueTap`) | | `SessionRow.swift` | полоса пятичасовой сессии; её процент нажимается так же, как цифры дня | @@ -260,16 +260,21 @@ mtime) — атомарная запись меняет inode, поэтому н тут же открылась по `handleClick`. 3. **Размер окна панели ведётся вручную** (`SizingHostingView`): `NSHostingView` не двигает окно, когда SwiftUI меняет высоту контента. -4. **Цвет нигде не единственный носитель смысла**: рядом с каждой полосой +4. **Монитор панели выбирается по кромке, а не по `anchor.screen`** + (`DropdownPanel.screen(for:)`): спрятанная в полноэкранном режиме строка меню + уводит окно значка за верхний край своего экрана — и на соседний монитор, + если тот стоит вплотную сверху. Своим считается экран, к чьей верхней кромке + значок ближе. +5. **Цвет нигде не единственный носитель смысла**: рядом с каждой полосой числа «факт / план», у перерасхода — значок ⚠. -5. **Родная палитра проверена валидатором** на дальтонизм и контраст; +6. **Родная палитра проверена валидатором** на дальтонизм и контраст; остальные темы — игровая площадка, о чём написано и в настройках. -6. **Колонка значений размечена по самой длинной своей строке** +7. **Колонка значений размечена по самой длинной своей строке** (`Theme.valueWidth` меряет «⚠ 100 / 100 %» шрифтом панели). Числом её задавать нельзя: кегль берётся у системного меню и растёт в универсальном доступе, а не влезшее значение переносится на второй ряд и тянет за собой высоту всей панели. Пункты на это отдаёт полоса — её длина условная. -7. **Сброс недели панель ждёт будильником, а не плановым опросом** +8. **Сброс недели панель ждёт будильником, а не плановым опросом** (`StatusItemController.scheduleResetRefresh`): момент известен заранее, он же написан в заголовке. `tick` раз в минуту работает страховкой на случай, когда будильник не сработал — машина спала, часы перевели, снимка на момент @@ -339,14 +344,16 @@ mtime) — атомарная запись меняет inode, поэтому н swift scripts/probe-usage.swift # один запрос, печатает ответ целиком ``` -### Разобрать «панель не открывается по щелчку» +### Разобрать «панель не открывается по щелчку» или «встала не там» ```bash swift scripts/probe-panel.swift --watch # сама погасла или это был щелчок swift scripts/probe-panel.swift --behaviors # прежде чем менять collectionBehavior +swift scripts/probe-panel.swift --displays # два монитора: где лежит окно значка ``` -Что означают строки и на что смотреть — [SPACES.md](SPACES.md). +Что означают строки и на что смотреть — [SPACES.md](SPACES.md): §1–6 про +рабочие столы, §7 про панель у чужого края на двух мониторах. --- @@ -492,6 +499,15 @@ Intel — собирает у себя, `install.sh` соберёт нативн - **`isShown` и `isOpen` — разные вопросы.** `isShown` («человек видит панель здесь») спрашивают `togglePanel` и `resize`; `isOpen` («есть ли что закрывать») — `close`, монитор кликов, `pinToAnchor`, `hideWhilePinned`. +- **`anchor.screen` врёт про монитор значка.** Строка меню в полноэкранном + режиме прячется, и её окно вместе со значком уезжает вверх ровно на свою + высоту — за верхнюю кромку своего экрана. Стоит сверху вплотную второй + монитор, и значок оказывается на его территории: `anchor.screen` отвечает + «второй», панель клэмпится по его границам и повисает у нижнего края чужого + монитора. `NSScreen.main` не спасает — это экран с клавиатурным фокусом, то + есть чужого окна. Экран выбирает `DropdownPanel.screen(for:)` по признаку, + который сдвиг строки меню переживает: у своего экрана верхняя кромка к значку + ближе всех. Разбор и замеры — в [SPACES.md](SPACES.md), §7. - **`byAdding: .hour` — это плюс 3600 секунд, а не «тот же час завтра».** Границы рабочего дня ищутся `date(bySettingHour:…)` от местной полуночи: прибавление часов давало в сутки осеннего перевода начало в 10:00, в сутки diff --git a/docs/L10N.md b/docs/L10N.md index f747c8f..cb1d4de 100644 --- a/docs/L10N.md +++ b/docs/L10N.md @@ -8,6 +8,12 @@ будут жить** переводы, **что сломается** по дороге и **в каком порядке** это делать. Кода он не содержит — это план, а не отчёт. +> **Статус: выполнено в 0.1.11** (2026-08-16). План читается как описание +> принятых решений — что переводим, где живут строки, как выбирается язык, — +> но написан он до работы и в будущем времени. Что вышло по факту: раздел +> [0.1.11 в журнале](../CHANGELOG.md) и [ROADMAP](ROADMAP.md#2026-08-16); +> нынешнее устройство — `Sources/ClaudeWeekCore/Localization.swift`. + ## Оглавление - [Сколько текста на самом деле](#сколько-текста-на-самом-деле) diff --git a/docs/PLAN.md b/docs/PLAN.md index 320b012..77cc26d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -508,6 +508,11 @@ codesign --force --sign - ClaudeWeek.app # ad-hoc подпись анимации, фон — материал `.menu` с тонкой вуалью поверх (`Theme.panelTint`), скругление 12 pt. `NSPopover` не подошёл: стрелку не убрать, отступает от строки и рисует свой непрозрачный фон. +- Место панели считается от окна значка, а монитор — по верхней кромке, к + которой значок ближе всего (`DropdownPanel.screen(for:)`). Спрашивать + `anchor.screen` нельзя: спрятанная в полноэкранном режиме строка меню уводит + значок за верхний край своего экрана, а у вплотную стоящих мониторов это уже + территория соседа — [SPACES.md](SPACES.md), §7. - Плотность вуали идёт по шкале с потолком `Theme.maxPanelTint` (0.55): на полной вуаль гасила материал целиком, и «прозрачный фон с размытием» отличался от сплошного одним оттенком. Глухой фон — отдельный режим @@ -648,7 +653,7 @@ assert-хелперов, запускаемый `swift run ClaudeWeekTests`; н = падение. Когда/если появится Xcode, таргет переносится на swift-testing без изменения самих проверок. -Что покрыто (336 проверок, `swift run ClaudeWeekTests`): +Что покрыто (439 проверок, `swift run ClaudeWeekTests`): 1. **Окно недели.** Момент ровно на сбросе; за минуту до и после; полсекунды до; воскресный полдень; смена таймзоны между вызовами; другой день сброса. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4244854..8f6bc26 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,10 +13,10 @@ - [Соседи по полке](#соседи-по-полке) — что умеют похожие программы - [Заметно сразу](#заметно-сразу) — 1–2 - [Заметно в углах](#заметно-в-углах) — 3–8 -- [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–12 +- [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–11 - [Что сознательно не делаем](#что-сознательно-не-делаем) - [Что закрыл аудит](#что-закрыл-аудит-2026-08-05) — журналы 2026-08-05, 06 и 07 -- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08 и 10 +- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16 и 17 --- @@ -174,25 +174,7 @@ XCTest без Xcode недоступен, поэтому проверки нап *Что делать:* список источников вместо одного и переключатель в панели. Работы на день, но польза сомнительна, пока никто не попросил. -### 11. Локализация только русская - -Тексты вшиты в код по-русски. Английской версии нет — а все соседи по полке -англоязычны, и это единственное, что мешает показать проект за пределами -своего стола. - -*Что делать:* план расписан отдельно — [L10N.md](L10N.md). Коротко: язык -следует за системным, с явным выбором «Системный / Русский / English» в -настройках; переводить надо 268 строк, которые видит пользователь, — из 331 -литерала с кириллицей остальные уходят только в лог. - -Прежняя оценка («`String(localized:)` и `.strings`, день работы, из которого -половина — вычитка») не учитывала, что `make-app.sh` кладёт в бандл один бинарь -и ресурсы SwiftPM в `.app` не переносит: с `.strings` пришлось бы чинить ещё и -сборку, причём поломка вылезла бы не на сборке, а у пользователя. Поэтому в -плане строки лежат таблицей в коде и проверяются тестом на полноту. Вычитка -по-прежнему половина работы. - -### 12. Сборки не нотаризованы +### 11. Сборки не нотаризованы Лицензия (MIT) и CI появились, релиз собирается по тегу и выкладывается `.dmg` под Apple Silicon; Intel-образа в релизах нет вовсе — там сборка из @@ -299,6 +281,14 @@ requirement не менялся и приложение держалось в с | автообновления не было: о новой версии узнавали случайно, ставили её тем же `install.sh` (бывший пункт 11) | программа спрашивает GitHub при запуске, раз в сутки и после сна; найденный выпуск показывает строкой в панели, а ставится он одной кнопкой на вкладке «О программе» — с обычными окнами macOS: заметки к релизу перед установкой, вопрос про перезапуск после. Кнопка качает образ релиза, сверяет SHA256 с `SHA256SUMS.txt`, проверяет подпись и версию внутри и заменяет бандл одним движением (`Updater`, `UpdateInstaller`, `UpdateController`, `--update`). Из меню по правому щелчку обновление убрано по той же причине, что и автозапуск: одно состояние — одно место. Sparkle не заводили: appcast и Developer ID для проекта без сертификата — лишняя площадка, а сумма из релиза даёт ту же проверку целостности | | синяя рамка фокуса вокруг ⚙ и ⟳ в футере панели: AppKit обводит нажатую кнопку прямоугольником, и на панели размером с меню он читается как ошибка вёрстки | `.focusEffectDisabled()` на обеих кнопках — то же решение, что уже стояло на кружке источника | +### 2026-08-10 + +| Было | Стало | +|---|---| +| релиз собирался на раннере `macos-15`, то есть с SDK 15, и на macOS 26 система рисовала такому бандлу прежнее плоское оформление: скачанная 0.1.5 выглядела старее сборки, собранной дома на CLT 26 | оба workflow идут на `macos-26`, а после сборки бандла сверяют SDK через `vtool` и падают, если раннер принёс что-то старее 26. Минимальная версия системы к SDK не привязана — она задана в `Package.swift` (`.macOS(.v14)`), поэтому образ по-прежнему запускается на macOS 14 и 15 | +| CI падал на `Keychain.swift`: `kSecUseAuthenticationUIFail` устарел с macOS 11, а `-warnings-as-errors` превращает предупреждение в ошибку. Замены у ключа нет — LAContext диалог не гасит, и это проверено | группа `DeprecatedDeclaration` понижена обратно до предупреждения флагом `-Wwarning`: оно остаётся в логе на виду, но сборку не роняет. Понижение общее на пакет, так что новое устаревшее место придётся замечать глазами | +| заводские настройки расходились с тем видом, к которому пришла обкатка: полоса вместо кольца, системная палитра, недельный ряд, круглые пороги 80/95 | дефолты — кольцо, `contrast`, компактная панель, вуаль 0.6, скругление 16, подпись сброса «и то, и другое», прогноз выключен, пороги 81/93 у недели и 81/95 у сессии. Картинки README снимаются заводским видом, и только недельный ряд для них задаётся явно: витрина показывает то, ради чего программа написана | + ### 2026-08-15 | Было | Стало | @@ -306,10 +296,14 @@ requirement не менялся и приложение держалось в с | уведомлений не было вовсе: о приближении к лимиту сообщал только цвет значка, и если панель не открывать, момент «осталось 10 %» проходил мимо (бывший пункт 1) | баннер macOS по своим порогам — два на недельный лимит (80 и 95 %) и два на пятичасовую сессию (75 и 95 %), вкладка «Уведомления» в настройках. В баннере лимит, живой процент, остаток и час сброса, рядом то же кольцо, что в строке меню, и того же цвета. Три правила из CCSeva на месте: об одном пороге один раз за окно лимита, только на ухудшении, остывание пять минут. Сказанное лежит в `alerts.json` и переживает перезапуск. Правила целиком в ядре (`AlertPlanner`) и покрыты тестами — показанное дважды уведомление глазами не ловится | | по плану уведомления должны были выключаться по умолчанию, чтобы виджет не стал источником шума | включены сразу: молчащая по умолчанию функция, ради которой её и добавляли, требует сначала найти вкладку. Шум держат не тумблером, а правилами выше — за неделю баннеров выходит четыре, по два на лимит. Выключается всё одним тумблером или по лимиту в отдельности | -### 2026-08-10 +### 2026-08-16 | Было | Стало | |---|---| -| релиз собирался на раннере `macos-15`, то есть с SDK 15, и на macOS 26 система рисовала такому бандлу прежнее плоское оформление: скачанная 0.1.5 выглядела старее сборки, собранной дома на CLT 26 | оба workflow идут на `macos-26`, а после сборки бандла сверяют SDK через `vtool` и падают, если раннер принёс что-то старее 26. Минимальная версия системы к SDK не привязана — она задана в `Package.swift` (`.macOS(.v14)`), поэтому образ по-прежнему запускается на macOS 14 и 15 | -| CI падал на `Keychain.swift`: `kSecUseAuthenticationUIFail` устарел с macOS 11, а `-warnings-as-errors` превращает предупреждение в ошибку. Замены у ключа нет — LAContext диалог не гасит, и это проверено | группа `DeprecatedDeclaration` понижена обратно до предупреждения флагом `-Wwarning`: оно остаётся в логе на виду, но сборку не роняет. Понижение общее на пакет, так что новое устаревшее место придётся замечать глазами | -| заводские настройки расходились с тем видом, к которому пришла обкатка: полоса вместо кольца, системная палитра, недельный ряд, круглые пороги 80/95 | дефолты — кольцо, `contrast`, компактная панель, вуаль 0.6, скругление 16, подпись сброса «и то, и другое», прогноз выключен, пороги 81/93 у недели и 81/95 у сессии. Картинки README снимаются заводским видом, и только недельный ряд для них задаётся явно: витрина показывает то, ради чего программа написана | +| интерфейс был только русский: тексты вшиты в код, а все соседи по полке англоязычны — это и мешало показать проект за пределами своего стола (бывший пункт 11) | английский интерфейс в 0.1.11. Язык следует за системным, а на вкладке «Общие» выбирается явно: «Как в системе», «Русский», «English»; переключается на ходу — язык лежит в конфиге, и панель с настройками перерисовываются по нему тем же движением, что и на любую другую правку. Переведено всё, что видит человек, вместе со склонениями дней, длительностей и единиц токенов; лог остаётся русским — одну ошибку не должно быть видно двумя разными строками. На один запуск язык задаётся ключом `--lang=system\|ru\|en`, им же снимаются английские картинки. Строки лежат таблицей в коде (`Localization.swift`, `L10n`) и проверяются тестом на полноту: `.strings` потребовали бы чинить ещё и сборку — `make-app.sh` кладёт в бандл один бинарь и ресурсы SwiftPM не переносит. План, по которому это делалось, — [L10N.md](L10N.md) | + +### 2026-08-17 + +| Было | Стало | +|---|---| +| на двух мониторах, поставленных один над другим, панель вставала не под строкой меню, а у нижнего края соседнего экрана — оторванная от значка на пол-экрана. Строка меню в полноэкранном режиме прячется, и её окно вместе со значком уезжает вверх на свою высоту: у вплотную стоящих мониторов этих 30 точек хватает, чтобы значок оказался на территории соседа, а `anchor.screen` честно ответил «соседний». Дальше панель клэмпилась по чужим границам — замер совпал с расчётом до точки: значок `y1080…1110` → панель `y1088…1416`, где `1088 = 1080 + 8` | экран выбирает `DropdownPanel.screen(for:)` по признаку, который сдвиг строки меню переживает: она всегда прижата к верхней кромке своего экрана, значит значок принадлежит тому из накрывающих его по горизонтали мониторов, чья верхняя кромка ближе. Верхняя кромка панели вдобавок ограничена `screen.maxY` — спрятанная строка меню уводила бы за край и её. Разбор и замеры — [SPACES.md](SPACES.md), §7; проба `probe-panel.swift --displays` | diff --git a/docs/SPACES.md b/docs/SPACES.md index 0315fea..a3a6c27 100644 --- a/docs/SPACES.md +++ b/docs/SPACES.md @@ -1,12 +1,19 @@ -# Панель и рабочие столы +# Панель, рабочие столы и мониторы -Разбор одного бага, на который заходили трижды: **панель не открывается по -щелчку — то не появляется вовсе, то мелькает и гаснет, то обнаруживается на -соседнем столе**. Два первых захода чинили симптом и делали хуже, потому что -опирались на признаки, которые в этом месте врут. +Разбор двух багов с одинаковой внешностью — «панель не там, где я» — и разными +причинами. -Документ нужен, чтобы третий раз не начинать с нуля: здесь измеренные данные, -готовые пробы и правило, по которому проверяется любая правка `DropdownPanel`. +**§1–6: панель не открывается по щелчку** — то не появляется вовсе, то мелькает +и гаснет, то обнаруживается на соседнем столе. Заходили трижды; два первых +захода чинили симптом и делали хуже, потому что опирались на признаки, которые +в этом месте врут. + +**§7: панель встаёт у чужого края на двух мониторах** — вплотную стоящие +экраны и спрятанная в полноэкранном режиме строка меню. + +Документ нужен, чтобы не начинать с нуля в четвёртый раз: здесь измеренные +данные, готовые пробы и правило, по которому проверяется любая правка +`DropdownPanel`. Смежное: [ARCHITECTURE.md](ARCHITECTURE.md) — карта кода и раздел «Грабли». @@ -158,6 +165,9 @@ swift scripts/probe-panel.swift --behaviors Чтением документации это не решается: поведение зависит от версии macOS и от того, полноэкранный стол или обычный. Проба даёт ответ за минуту. +**Шаг 4. Панель появляется, но не там.** Если мониторов два — это не про столы, +дальше читайте §7 и запускайте `--displays`. + ## 6. Чего не делать - **Не возвращать `.canJoinAllSpaces`** без замены механизма целиком: с ним @@ -174,3 +184,69 @@ swift scripts/probe-panel.swift --behaviors - **Не проверять правку на одном столе.** Минимальный прогон: открыть панель и уйти свайпом; вернуться и щёлкнуть; щёлкнуть сразу после свайпа, не дожидаясь конца анимации; повторить в полноэкранном окне чужого приложения. + +--- + +## 7. Два монитора: панель у чужого края + +Отдельная жалоба с той же внешностью — «панель вылезла за экран», — но причина +другая, и столы к ней отношения не имеют. + +**Что видно.** Два монитора один над другим, на нижнем открыто полноэкранное +окно (VS Code, zen mode). Щелчок по значку — и панель появляется не под строкой +меню, а внизу *верхнего* монитора, оторванная от неё на пол-экрана. + +**Почему.** Строка меню в полноэкранном режиме прячется, и WindowServer уводит +её окно вместе со значками вверх ровно на свою высоту — за верхнюю кромку +своего экрана. Замер на машине с двумя DELL 1920×1080 (нижний экран +`y 0…1080`, верхний `y 1080…2160`): + +``` +layer 24 Window Server x0 w1920 h30 AppKit y 1050…1080 экран 1 ← строка меню видна +layer 24 Window Server x0 w1920 h30 AppKit y 1080…1110 экран 2 ← она же спрятана +``` + +Спрятанная строка меню нижнего экрана лежит на территории верхнего: мониторы +стоят вплотную, и 30 точек сдвига переносят её через границу. `anchor.screen` +отвечает «экран 2» — честно и бесполезно. Дальше срабатывал клэмп +`origin.y = max(origin.y, screen.minY + margin)`, и панель прижималась к нижней +кромке верхнего монитора. Расчёт совпал с измеренным положением до точки: + +``` +значок y1080…1110 → было: экран 2, панель y1088…1416 (1088 = 1080 + 8) + стало: экран 1, панель y752…1080 +``` + +**Чем чинится.** `DropdownPanel.screen(for:)` выбирает экран не по +`anchor.screen`, а по признаку, который сдвиг переживает: строка меню всегда +прижата к верхней кромке своего экрана, значит значок принадлежит тому из +накрывающих его по горизонтали экранов, чья верхняя кромка к нему ближе. +Плюс верхняя кромка панели ограничена сверху `screen.maxY`: спрятанная строка +меню уводила бы за край и её. + +**Чего не делать.** Не подставлять `NSScreen.main` — это экран с клавиатурным +фокусом, то есть чужого активного окна, а не нашего значка. Не искать экран +пересечением с рамкой значка: у спрятанной строки меню оно приводит к соседу. + +**Как проверить.** `swift scripts/probe-panel.swift --displays` — заводит свой +значок в строке меню и печатает про него оба ответа: «по рамке» (так отвечает +`anchor.screen`) и «по кромке» (так выбирает панель). Строка с работающей +машины, полноэкранное окно на нижнем мониторе: + +``` +значок: по рамке нет по кромке 2 x-874 y2130…2160 +``` + +«По рамке» здесь даже не «сосед», а «нет»: значок не влез в строку меню и уехал +левее всех экранов, так что `NSWindow.screen` отвечает `nil` — и панель ушла бы +считаться от `NSScreen.main`, то есть от экрана чужого окна. Признак по кромке +отвечает и в этом случае. + +Значок самого ClaudeWeek для такой пробы не годится: на macOS 26 окна пунктов +строки меню принадлежат «Пункту управления», и по pid приложения +`CGWindowListCopyWindowInfo` их не отдаёт вовсе — прежняя версия пробы молча +печатала строку без значка. + +Прогон: щёлкнуть по значку на каждом мониторе; открыть на нижнем полноэкранное +окно и щёлкнуть снова; повторить с полноэкранным на верхнем. Так правка и +проверена — 2026-08-17, macOS 26.5, два DELL 1920×1080 один над другим. diff --git a/docs/USAGE.md b/docs/USAGE.md index bbaceea..4a204aa 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -159,6 +159,12 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 или по Esc. Правый клик по иконке даёт обычное меню: «Обновить», «Настройки…», «О программе», «Выйти». +Панель приходит туда, где вы сейчас: на текущий рабочий стол и под ту строку +меню, в которой стоит значок, — на втором мониторе так же, как на основном. +Уходите на другой стол — панель закрывается, как это делает системное меню. +Полноэкранное окно ей не помеха: она рисуется поверх, а строку меню в этот +момент система прячет, и панель приклеивается к верхней кромке экрана. + Условные обозначения на полосе: ``` diff --git a/scripts/probe-panel.swift b/scripts/probe-panel.swift index 022e313..f5cb204 100755 --- a/scripts/probe-panel.swift +++ b/scripts/probe-panel.swift @@ -23,8 +23,13 @@ // как у панели. Отвечает на вопросы, от которых зависит весь показ на // втором мониторе: что говорит `isOnActiveSpace` про окно на соседнем // мониторе, когда столы переключают на этом, и остаётся ли окно на своём -// мониторе при показе. Заодно печатает, где лежит окно значка -// ClaudeWeek — панель считает место от него. +// мониторе при показе. Заодно заводит свой значок в строке меню — панель +// считает место от такого же — и печатает про него два ответа: «по рамке» +// (так отвечал бы `anchor.screen`) и «по кромке» (так выбирает +// `DropdownPanel.screen(for:)`). Расходятся они там, где строка меню +// спрятана полноэкранным окном, см. docs/SPACES.md §7. Значок ClaudeWeek +// для этого не годится: на macOS 26 окна пунктов строки меню принадлежат +// «Пункту управления», и по pid приложения в списке окон их нет. // // В обоих режимах пройдитесь по рабочим столам — свайпом или ⌃→. Разбор // собранного и что с ним делать: docs/SPACES.md. @@ -203,11 +208,6 @@ func compareBehaviors() -> Never { // MARK: - Режим --displays -/// Слой окна пункта строки меню: `NSWindow.Level.statusBar`. По нему в списке -/// окон находится значок ClaudeWeek — панель считает место от его рамки, и на -/// двух мониторах важно, где эта рамка лежит в момент щелчка. -let statusLayer = 25 - /// Номер монитора под точкой (координаты AppKit, снизу вверх). func display(at point: NSPoint) -> String { guard let index = NSScreen.screens.firstIndex(where: { $0.frame.contains(point) }) @@ -215,16 +215,23 @@ func display(at point: NSPoint) -> String { return "\(index + 1)" } -/// Номер монитора под окном из списка окон. Список отдаёт координаты сверху -/// вниз от главного экрана, NSScreen — снизу вверх; без приведения к одному -/// виду монитор определяется неверно ровно на втором экране. -func display(ofListed rect: CGRect) -> String { - let top = NSScreen.screens[0].frame.maxY - return display(at: NSPoint(x: rect.midX, y: top - rect.midY)) +/// Монитор значка так, как его выбирает панель (`DropdownPanel.screen(for:)`): +/// строка меню всегда прижата к верхней кромке своего экрана, поэтому значок +/// принадлежит тому из накрывающих его по горизонтали мониторов, чья верхняя +/// кромка к нему ближе. Ответ расходится с «по рамке» ровно тогда, когда строку +/// меню спрятало полноэкранное окно, а сосед стоит вплотную сверху. +func display(byEdge icon: NSRect) -> String { + let covering = NSScreen.screens.filter { $0.frame.minX <= icon.midX && icon.midX <= $0.frame.maxX } + guard let screen = (covering.isEmpty ? NSScreen.screens : covering) + .min(by: { abs($0.frame.maxY - icon.maxY) < abs($1.frame.maxY - icon.maxY) }), + let index = NSScreen.screens.firstIndex(of: screen) + else { return "—" } + return "\(index + 1)" } /// Монитор, который окно считает своим (`NSWindow.screen` — тот, где лежит -/// большая часть окна). «нет» у снятого с экрана окна. +/// большая часть окна). «нет» у снятого с экрана окна и у окна, не попавшего +/// ни на один экран, — так отвечает и окно значка при спрятанной строке меню. func display(ofWindow window: NSWindow) -> String { guard let screen = window.screen, let index = NSScreen.screens.firstIndex(of: screen) @@ -232,25 +239,6 @@ func display(ofWindow window: NSWindow) -> String { return "\(index + 1)" } -func claudeWeekPID() -> pid_t? { - NSWorkspace.shared.runningApplications.first { - $0.localizedName == "ClaudeWeek" || $0.executableURL?.lastPathComponent == "ClaudeWeek" - }?.processIdentifier -} - -/// Рамка окна значка — того самого `anchor`, от которого считается панель. -func statusItemFrame(pid: pid_t) -> CGRect? { - guard let list = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]], - let item = list.first(where: { - ($0[kCGWindowOwnerPID as String] as? pid_t) == pid - && ($0[kCGWindowLayer as String] as? Int) == statusLayer - }), - let bounds = item[kCGWindowBounds as String] as? [String: CGFloat] - else { return nil } - return CGRect(x: bounds["X"] ?? 0, y: bounds["Y"] ?? 0, - width: bounds["Width"] ?? 0, height: bounds["Height"] ?? 0) -} - func compareDisplays() -> Never { let screens = NSScreen.screens guard screens.count > 1 else { @@ -267,13 +255,21 @@ func compareDisplays() -> Never { on: screen ) } - let pid = claudeWeekPID() - if pid == nil { say("ClaudeWeek не запущен — про значок строки меню данных не будет") } + // Свой значок в строке меню. Чужой отсюда не измерить: на macOS 26 окна + // пунктов строки меню принадлежат «Пункту управления», а не приложению, + // и по pid ClaudeWeek в списке окон не находятся вовсе. Свой ведёт себя + // так же — он в той же строке меню и прячется вместе с ней, а рамку и + // экран отдаёт прямо, без списка окон. + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "◱" func status() -> String { var parts = ["активный стол:\(activeSpace())"] - if let pid, let rect = statusItemFrame(pid: pid) { - parts.append(String(format: "значок: монитор %@ x%.0f", display(ofListed: rect), rect.midX)) + if let icon = item.button?.window { + let frame = icon.frame + parts.append(String(format: "значок: по рамке %@ по кромке %@ x%.0f y%.0f…%.0f", + display(ofWindow: icon), display(byEdge: frame), + frame.midX, frame.minY, frame.maxY)) } for (index, strip) in strips.enumerated() { parts.append("D\(index + 1): монитор=\(display(ofWindow: strip))" @@ -285,6 +281,8 @@ func compareDisplays() -> Never { say("по полоске на каждом мониторе, 70 секунд. Переключайте столы на обоих" + " мониторах и щёлкайте по значку — на том мониторе и на соседнем") + say("у своего значка смотрите на y и на выбор монитора: по x он стоит там," + + " где нашлось место, а не влезший в строку меню уезжает за левый край") NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in let point = NSEvent.mouseLocation