From 6a53a909eccc3a1d43a09bf8bd3f26748ad32666 Mon Sep 17 00:00:00 2001 From: AndreeTorres <00015722@uca.edu.sv> Date: Tue, 28 Jul 2026 10:05:00 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20atajos=20de=20edici=C3=B3n=20en=20macOS?= =?UTF-8?q?=2015=20(paneles=20no-activantes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El arreglo de 2.2.1 instalaba un menú Editar oculto y confiaba en que AppKit repartiera ⌘C/⌘V/⌘X/⌘A/⌘Z por ahí. Corner es accesoria (LSUIElement) y sus paneles son .nonactivatingPanel: nunca es la app activa ni dueña de la barra de menú, así que esa ruta no es fiable. Funciona en macOS 26 y no en macOS 15. Ahora los resuelve el propio panel en performKeyEquivalent, enviando la acción por la cadena de responders desde el field editor. No depende de la barra de menú ni de la versión del sistema. Se agrega ⇧⌘Z. La política de qué significa cada tecla queda pura en Domain/EditingShortcuts con tests; el menú oculto se elimina (también hacía aparecer una barra de menú con un solo "Editar" al mostrar alertas). --- CHANGELOG.md | 12 +++++ Corner/App/AppDelegate.swift | 28 ----------- Corner/Domain/EditingShortcuts.swift | 51 ++++++++++++++++++++ Corner/Resources/Localizable.xcstrings | 62 +------------------------ Corner/System/FloatingPanel.swift | 43 +++++++++++++++++ CornerTests/EditingShortcutsTests.swift | 59 +++++++++++++++++++++++ docs/SPEC.md | 22 ++++++--- project.yml | 2 +- 8 files changed, 182 insertions(+), 97 deletions(-) create mode 100644 Corner/Domain/EditingShortcuts.swift create mode 100644 CornerTests/EditingShortcutsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c68d9db..9c795b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es/). Fechas en formato AAAA-MM-DD. +## [2.2.2] — 2026-07-28 + +### Corregido +- **Atajos de edición en macOS 15 (Sonoma/Sequoia)**: el arreglo de 2.2.1 se + apoyaba en el menú Editar de la app, y Corner —accesoria y con paneles + no-activantes— nunca es la app activa ni dueña de la barra de menú. Esa ruta + funciona en macOS 26 pero no en macOS 15, así que ⌘C, ⌘V, ⌘X, ⌘A y ⌘Z + seguían sin hacer nada ahí. Ahora los resuelve el propio panel, sin depender + de la barra de menú ni de la versión del sistema. Se agrega ⇧⌘Z (rehacer). +- Al mostrar una alerta ya no aparecía una barra de menú con un único menú + "Editar" (efecto secundario del menú oculto que instalaba 2.2.1). + ## [2.2.1] — 2026-07-25 ### Corregido diff --git a/Corner/App/AppDelegate.swift b/Corner/App/AppDelegate.swift index 80af583..1447194 100644 --- a/Corner/App/AppDelegate.swift +++ b/Corner/App/AppDelegate.swift @@ -125,7 +125,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } AppAccess.shared = self - installEditMenu() menuBar.install() hotCorner.start() @@ -142,33 +141,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - /// Standard editing shortcuts (⌘C/⌘V/⌘X/⌘A/⌘Z) are dispatched by AppKit - /// through the main menu's Edit items. An accessory app (LSUIElement) has - /// no main menu, so without this they never reach the panel's text fields. - /// The menu is never displayed — it exists only to route key equivalents. - private func installEditMenu() { - let edit = NSMenu(title: "Edit") - edit.addItem(withTitle: String(localized: "Deshacer"), - action: Selector(("undo:")), keyEquivalent: "z") - edit.addItem(withTitle: String(localized: "Rehacer"), - action: Selector(("redo:")), keyEquivalent: "Z") - edit.addItem(.separator()) - edit.addItem(withTitle: String(localized: "Cortar"), - action: #selector(NSText.cut(_:)), keyEquivalent: "x") - edit.addItem(withTitle: String(localized: "Copiar"), - action: #selector(NSText.copy(_:)), keyEquivalent: "c") - edit.addItem(withTitle: String(localized: "Pegar"), - action: #selector(NSText.paste(_:)), keyEquivalent: "v") - edit.addItem(withTitle: String(localized: "Seleccionar todo"), - action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") - - let editItem = NSMenuItem() - editItem.submenu = edit - let mainMenu = NSMenu() - mainMenu.addItem(editItem) - NSApp.mainMenu = mainMenu - } - private var cursorScreen: NSScreen? { NSScreen.screens.first(where: { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) diff --git a/Corner/Domain/EditingShortcuts.swift b/Corner/Domain/EditingShortcuts.swift new file mode 100644 index 0000000..3612c45 --- /dev/null +++ b/Corner/Domain/EditingShortcuts.swift @@ -0,0 +1,51 @@ +import Foundation + +/// The standard text-editing commands macOS expects in any text field. +enum EditingCommand: CaseIterable { + case undo, redo, cut, copy, paste, selectAll + + /// The action AppKit routes through the responder chain for this command. + /// `undo:` / `redo:` have no public Swift selector, hence the string form. + var selector: Selector { + switch self { + case .undo: Selector(("undo:")) + case .redo: Selector(("redo:")) + case .cut: Selector(("cut:")) + case .copy: Selector(("copy:")) + case .paste: Selector(("paste:")) + case .selectAll: Selector(("selectAll:")) + } + } +} + +/// Which editing command a key press means, as pure policy. +/// +/// Corner's panels are non-activating: the app never becomes active, so AppKit +/// does not reliably route these shortcuts through the main menu the way it +/// does for a normal foreground app (it does on macOS 26, it does not on +/// macOS 15). The panel therefore resolves them itself, and the decision of +/// *what* a key press means lives here so it can be tested without AppKit. +enum EditingShortcuts { + /// - Parameter key: `charactersIgnoringModifiers`, case-insensitive. + /// - Returns: the command, or `nil` when the press is not an editing shortcut. + static func command( + for key: String, + command: Bool, + shift: Bool = false, + option: Bool = false, + control: Bool = false + ) -> EditingCommand? { + // Only plain ⌘ (optionally with ⇧ for redo). ⌥⌘C and friends belong to + // whoever else wants them. + guard command, !option, !control else { return nil } + + switch key.lowercased() { + case "z": return shift ? .redo : .undo + case "x": return shift ? nil : .cut + case "c": return shift ? nil : .copy + case "v": return shift ? nil : .paste + case "a": return shift ? nil : .selectAll + default: return nil + } + } +} diff --git a/Corner/Resources/Localizable.xcstrings b/Corner/Resources/Localizable.xcstrings index 4a891e9..ff43679 100644 --- a/Corner/Resources/Localizable.xcstrings +++ b/Corner/Resources/Localizable.xcstrings @@ -240,16 +240,6 @@ } } }, - "Copiar": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy" - } - } - } - }, "Corner no pudo guardar en disco": { "extractionState": "manual", "localizations": { @@ -283,16 +273,6 @@ } } }, - "Cortar": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cut" - } - } - } - }, "Crear": { "extractionState": "manual", "localizations": { @@ -326,16 +306,6 @@ } } }, - "Deshacer": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Undo" - } - } - } - }, "Editar…": { "extractionState": "manual", "localizations": { @@ -564,16 +534,6 @@ } } }, - "Pegar": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Paste" - } - } - } - }, "Permiso de Recordatorios denegado": { "localizations": { "en": { @@ -624,16 +584,6 @@ } } }, - "Rehacer": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Redo" - } - } - } - }, "Revisa tu conexión a internet e intenta de nuevo.": { "extractionState": "manual", "localizations": { @@ -656,16 +606,6 @@ } } }, - "Seleccionar todo": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Select All" - } - } - } - }, "Semana": { "extractionState": "manual", "localizations": { @@ -755,4 +695,4 @@ } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/Corner/System/FloatingPanel.swift b/Corner/System/FloatingPanel.swift index 8cde629..91734cd 100644 --- a/Corner/System/FloatingPanel.swift +++ b/Corner/System/FloatingPanel.swift @@ -6,4 +6,47 @@ import AppKit final class FloatingPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } + + /// Standard editing shortcuts (⌘Z, ⇧⌘Z, ⌘X, ⌘C, ⌘V, ⌘A) in the panel's + /// text fields. + /// + /// AppKit normally delivers these from the main menu's Edit items. Corner + /// is an accessory app (LSUIElement) whose panels are `.nonactivatingPanel`, + /// so it never becomes the active application and never owns the menu bar — + /// that route works on macOS 26 but not on macOS 15, where the shortcuts + /// simply did nothing. Resolving them here is independent of both the menu + /// bar and the OS version: the panel is the key window, so it always gets + /// the event. + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Anything the content view claims (SwiftUI shortcuts) wins. + if super.performKeyEquivalent(with: event) { return true } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard let key = event.charactersIgnoringModifiers, + let command = EditingShortcuts.command( + for: key, + command: flags.contains(.command), + shift: flags.contains(.shift), + option: flags.contains(.option), + control: flags.contains(.control) + ) + else { return false } + + return perform(command) + } + + /// Sends the command down the responder chain from this window's first + /// responder, which is the field editor while the user is typing. + private func perform(_ command: EditingCommand) -> Bool { + if NSApp.sendAction(command.selector, to: nil, from: self) { return true } + + // `undo:` / `redo:` are supplied by the field editor's undo manager, + // which is not always reachable as an action target. + guard let undoManager = firstResponder?.undoManager else { return false } + switch command { + case .undo where undoManager.canUndo: undoManager.undo(); return true + case .redo where undoManager.canRedo: undoManager.redo(); return true + default: return false + } + } } diff --git a/CornerTests/EditingShortcutsTests.swift b/CornerTests/EditingShortcutsTests.swift new file mode 100644 index 0000000..228a84f --- /dev/null +++ b/CornerTests/EditingShortcutsTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import Corner + +/// Which key press means which editing command (SPEC §2, "Atajos de edición"). +struct EditingShortcutsTests { + + @Test func recognizesTheStandardCommands() { + #expect(EditingShortcuts.command(for: "x", command: true) == .cut) + #expect(EditingShortcuts.command(for: "c", command: true) == .copy) + #expect(EditingShortcuts.command(for: "v", command: true) == .paste) + #expect(EditingShortcuts.command(for: "a", command: true) == .selectAll) + #expect(EditingShortcuts.command(for: "z", command: true) == .undo) + } + + /// ⇧⌘Z is redo; with shift the rest are not editing shortcuts at all. + @Test func shiftOnlyMattersForRedo() { + #expect(EditingShortcuts.command(for: "z", command: true, shift: true) == .redo) + for key in ["x", "c", "v", "a"] { + #expect(EditingShortcuts.command(for: key, command: true, shift: true) == nil) + } + } + + /// `charactersIgnoringModifiers` reports an uppercase letter while ⇧ is held. + @Test func isCaseInsensitive() { + #expect(EditingShortcuts.command(for: "C", command: true) == .copy) + #expect(EditingShortcuts.command(for: "Z", command: true, shift: true) == .redo) + } + + /// Without ⌘ these are plain typing and must reach the text field untouched. + @Test func ignoresPressesWithoutCommand() { + for key in ["x", "c", "v", "a", "z"] { + #expect(EditingShortcuts.command(for: key, command: false) == nil) + } + } + + /// ⌥⌘C / ⌃⌘C and friends belong to whoever else claims them. + @Test func ignoresOtherModifierCombinations() { + #expect(EditingShortcuts.command(for: "c", command: true, option: true) == nil) + #expect(EditingShortcuts.command(for: "c", command: true, control: true) == nil) + #expect(EditingShortcuts.command(for: "z", command: true, option: true) == nil) + } + + @Test func ignoresUnrelatedKeys() { + #expect(EditingShortcuts.command(for: "q", command: true) == nil) + #expect(EditingShortcuts.command(for: " ", command: true) == nil) + #expect(EditingShortcuts.command(for: "", command: true) == nil) + } + + /// Every command must resolve to the action name AppKit routes. + @Test func mapsEachCommandToItsResponderAction() { + let expected: [EditingCommand: String] = [ + .undo: "undo:", .redo: "redo:", .cut: "cut:", + .copy: "copy:", .paste: "paste:", .selectAll: "selectAll:", + ] + for command in EditingCommand.allCases { + #expect(command.selector.description == expected[command]) + } + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 7bb9660..42d8f30 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -31,13 +31,21 @@ Tener las tareas del día y de la semana a un gesto de distancia — mover el mo inferior izquierda · ⌥Espacio", con botón ✕. Persiste entre aperturas hasta que se descarta; descartada, no vuelve jamás (UserDefaults). -### Atajos de edición estándar (v2.2.1, 2026-07-25) - -- En los campos del panel y de la captura rápida funcionan **⌘C, ⌘V, ⌘X, ⌘A y - ⌘Z**. AppKit reparte esos atajos por los ítems del menú Editar de la app, y - Corner —al ser accesoria (`LSUIElement`)— no tenía menú principal: por eso - las teclas no llegaban a los campos. Se instala un menú Editar que **nunca se - muestra**; existe solo para enrutar las teclas. +### Atajos de edición estándar (v2.2.1, 2026-07-25; corregido en v2.2.2, 2026-07-28) + +- En los campos del panel y de la captura rápida funcionan **⌘Z, ⇧⌘Z, ⌘X, ⌘C, + ⌘V y ⌘A**. +- AppKit reparte normalmente esos atajos por los ítems del menú Editar de la + app. Corner es accesoria (`LSUIElement`) y sus paneles son + `.nonactivatingPanel`: **nunca se vuelve la app activa ni es dueña de la barra + de menú**, así que esa ruta no es fiable. En macOS 26 funcionaba; en macOS 15 + las teclas no hacían nada. +- Por eso los resuelve el propio panel en `performKeyEquivalent`, enviando la + acción por la cadena de responders. No depende de la barra de menú ni de la + versión del sistema, porque el panel siempre es la ventana con el foco. +- Lo que significa cada tecla es política pura y con tests + (`Domain/EditingShortcuts.swift`). Lo que reclame SwiftUI tiene prioridad; con + ⌥ o ⌃ de por medio el atajo no es nuestro y se deja pasar. ### Cierre El panel se cierra cuando ocurre cualquiera de: diff --git a/project.yml b/project.yml index 14bf074..446f0fb 100644 --- a/project.yml +++ b/project.yml @@ -12,7 +12,7 @@ options: settings: base: SWIFT_VERSION: "6.0" - MARKETING_VERSION: "2.2.1" + MARKETING_VERSION: "2.2.2" CURRENT_PROJECT_VERSION: "1" MACOSX_DEPLOYMENT_TARGET: "14.0" CODE_SIGN_STYLE: Automatic