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