Skip to content

Commit bd53fa2

Browse files
committed
Merge fix-composer-kbd: composer keyboard context + New message button
2 parents e604a68 + 9387108 commit bd53fa2

6 files changed

Lines changed: 56 additions & 2 deletions

File tree

Sources/HudsonUI/Model/AppModel.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,10 @@ public final class AppModel {
337337
/// event — derived, never stored, so it can never drift from the
338338
/// overlay flags that actually drive what's on screen.
339339
var keyboardContext: KeyboardContext {
340+
// The composer is a text-entry modal — it takes keyboard priority so
341+
// the list's single-letter triage shortcuts never eat characters you're
342+
// typing into an email.
343+
if isComposerVisible { return .composer }
340344
if isPaletteVisible { return .palette }
341345
if isSearchVisible { return .search }
342346
return .list
@@ -362,6 +366,7 @@ public final class AppModel {
362366
case .togglePalette: togglePalette()
363367
case .toggleSearch: toggleSearch()
364368
case .composeNew: composeNew()
369+
case .closeComposer: isComposerVisible = false
365370
}
366371
}
367372

Sources/HudsonUI/Model/KeyboardMap.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import Foundation
77
public enum KeyboardContext: Sendable, Equatable {
88
case palette
99
case search
10+
/// The compose/reply sheet is open and owns the keyboard: EVERY key is
11+
/// typed into the body (only Esc is special), so the list's single-letter
12+
/// triage shortcuts (j/k/e/s/u/o) must NOT fire — otherwise you can't type
13+
/// those letters in an email.
14+
case composer
1015
case list
1116
}
1217

@@ -57,6 +62,7 @@ public enum KeyAction: Sendable, Equatable {
5762
case togglePalette
5863
case toggleSearch
5964
case composeNew
65+
case closeComposer
6066
}
6167

6268
/// Hudson's global keymap, as a single pure function: `(context, key) ->
@@ -79,10 +85,20 @@ public enum KeyRouter {
7985
switch context {
8086
case .palette: return routeInPalette(event)
8187
case .search: return routeInSearch(event)
88+
case .composer: return routeInComposer(event)
8289
case .list: return routeInList(event)
8390
}
8491
}
8592

93+
/// The compose/reply sheet owns the keyboard entirely: only Esc is routed
94+
/// (to close the sheet); EVERYTHING else — every letter, including j/k/e/s/
95+
/// u/o — passes straight through to the body field. Without this, the
96+
/// list's single-letter triage shortcuts would eat those letters and you
97+
/// couldn't type them in an email.
98+
private static func routeInComposer(_ event: KeyDescriptor) -> KeyAction? {
99+
event.special == .escape ? .closeComposer : nil
100+
}
101+
86102
/// Arrow/Return/Esc drive the palette's highlight and dismissal; every
87103
/// other key (ordinary typing) passes through untouched so the query
88104
/// field keeps working — CONSUME only what we explicitly route.

Sources/HudsonUI/Views/RootView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ public struct RootView: View {
148148
break
149149
}
150150
},
151-
onSyncNow: { Task { await model.syncNow() } })
151+
onSyncNow: { Task { await model.syncNow() } },
152+
onCompose: { model.composeNew() })
152153
.frame(minWidth: 200, idealWidth: Metrics.sidebarWidth, maxWidth: 300)
153154

154155
InboxListView(inbox: model.inbox, onOpen: { model.openThread($0) })

Sources/HudsonUI/Views/SidebarView.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ public struct SidebarView: View {
3434
private let onSelect: (Selection) -> Void
3535
private let onSyncNow: () -> Void
3636
private let onSettings: () -> Void
37+
private let onCompose: () -> Void
3738

3839
public init(
3940
accountEmail: String?, unreadCount: Int, labels: [LabelRecord], pendingCount: Int,
4041
isSyncing: Bool = false, syncBanner: String? = nil,
4142
selection: Selection, onSelect: @escaping (Selection) -> Void,
42-
onSyncNow: @escaping () -> Void = {}, onSettings: @escaping () -> Void = {}
43+
onSyncNow: @escaping () -> Void = {}, onSettings: @escaping () -> Void = {},
44+
onCompose: @escaping () -> Void = {}
4345
) {
4446
self.accountEmail = accountEmail
4547
self.unreadCount = unreadCount
@@ -51,6 +53,7 @@ public struct SidebarView: View {
5153
self.onSelect = onSelect
5254
self.onSyncNow = onSyncNow
5355
self.onSettings = onSettings
56+
self.onCompose = onCompose
5457
}
5558

5659
public var body: some View {
@@ -64,6 +67,12 @@ public struct SidebarView: View {
6467
.foregroundStyle(Palette.ink)
6568
.lineLimit(1)
6669
.truncationMode(.middle)
70+
.padding(.horizontal, Metrics.unit * 3)
71+
.padding(.bottom, Metrics.unit * 3)
72+
73+
// The primary "start a new email" affordance (also ⌘N). Was
74+
// missing — you could reply but not compose from scratch.
75+
PrimaryButton(title: "New message", action: onCompose)
6776
.padding(.horizontal, Metrics.unit * 3)
6877
.padding(.bottom, Metrics.unit * 4)
6978

Tests/HudsonUITests/AppModelTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,14 @@ import Testing
323323
model.startAutoSync(makeStack: { nil }) // idempotent
324324
#expect(model.isAutoSyncActive == true)
325325
}
326+
327+
/// When the composer is visible, keyboard routing switches to the `.composer`
328+
/// context (so triage letters don't eat what you're typing).
329+
@MainActor
330+
@Test func keyboardContextIsComposerWhenComposerVisible() {
331+
let db = try! HudsonDatabase.inMemory()
332+
let model = AppModel(database: db, account: nil)
333+
#expect(model.keyboardContext == .list)
334+
model.isComposerVisible = true
335+
#expect(model.keyboardContext == .composer)
336+
}

Tests/HudsonUITests/KeyboardMapTests.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,15 @@ import Testing
100100
#expect(KeyRouter.route(KeyDescriptor(special: .upArrow), context: .list) == nil)
101101
#expect(KeyRouter.route(KeyDescriptor(special: .downArrow), context: .list) == nil)
102102
}
103+
104+
/// Regression: the compose/reply sheet owns the keyboard. Single letters that
105+
/// are list triage shortcuts (s/o/k/j/e/u) MUST pass through so you can type
106+
/// them into an email — only Esc is routed (to close the sheet). This is the
107+
/// "can't type s/o/k when replying" bug.
108+
@Test func composerContextLetsTypingThroughAndOnlyEscCloses() {
109+
for letter in ["s", "o", "k", "j", "e", "u", "a", "z"] {
110+
#expect(KeyRouter.route(KeyDescriptor(characters: letter), context: .composer) == nil)
111+
}
112+
#expect(KeyRouter.route(KeyDescriptor(special: .return), context: .composer) == nil)
113+
#expect(KeyRouter.route(KeyDescriptor(special: .escape), context: .composer) == .closeComposer)
114+
}

0 commit comments

Comments
 (0)