Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 0 additions & 28 deletions Corner/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
AppAccess.shared = self

installEditMenu()
menuBar.install()
hotCorner.start()

Expand All @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions Corner/Domain/EditingShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
62 changes: 1 addition & 61 deletions Corner/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,6 @@
}
}
},
"Copiar": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy"
}
}
}
},
"Corner no pudo guardar en disco": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -283,16 +273,6 @@
}
}
},
"Cortar": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cut"
}
}
}
},
"Crear": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -326,16 +306,6 @@
}
}
},
"Deshacer": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Undo"
}
}
}
},
"Editar…": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -564,16 +534,6 @@
}
}
},
"Pegar": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Paste"
}
}
}
},
"Permiso de Recordatorios denegado": {
"localizations": {
"en": {
Expand Down Expand Up @@ -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": {
Expand All @@ -656,16 +606,6 @@
}
}
},
"Seleccionar todo": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Select All"
}
}
}
},
"Semana": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -755,4 +695,4 @@
}
},
"version": "1.0"
}
}
43 changes: 43 additions & 0 deletions Corner/System/FloatingPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
59 changes: 59 additions & 0 deletions CornerTests/EditingShortcutsTests.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
22 changes: 15 additions & 7 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading