From 0ada190d1ed1b3339ba69e7093c001594ef2db2a Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:01:16 -0700 Subject: [PATCH 1/2] feat(writing): make custom Length min/max editable Replace the cramped side-by-side steppers with stacked Minimum and Maximum rows, each an editable numeric field paired with up/down arrows. Typed and stepped values both commit through setCustomWordCountRange, so they stay clamped to [1, 50] with Max >= Min. Users can now type a value directly instead of only nudging the arrows, and the layout reads more clearly. --- .../UI/Settings/Panes/WritingPaneView.swift | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/Cotabby/UI/Settings/Panes/WritingPaneView.swift b/Cotabby/UI/Settings/Panes/WritingPaneView.swift index 56209177..2915ec4d 100644 --- a/Cotabby/UI/Settings/Panes/WritingPaneView.swift +++ b/Cotabby/UI/Settings/Panes/WritingPaneView.swift @@ -24,23 +24,18 @@ struct WritingPaneView: View { ) } - // Two steppers appear only while Custom is active so the curated picker stays the - // common path. `customRangeChanged` re-routes through `setCustomWordCountRange` so - // the values are clamped and high stays >= low even if the user spams Down on high. + // Min and Max are editable while Custom is active: type a value or nudge it with the + // arrows. Both rows commit through `setCustomWordCountRange`, which clamps to + // [minimumWord, maximumWord] and keeps Max >= Min, so neither a typed nor a stepped + // value can leave the sensible range. Stacked as their own rows (rather than the old + // side-by-side steppers) so each reads as one editable field. Shown only in Custom so + // the curated picker stays the common path. if suggestionSettings.isUsingCustomWordCountRange { - HStack(spacing: 16) { - Stepper( - value: customLowBinding, - in: SuggestionWordRange.minimumWord...SuggestionWordRange.maximumWord - ) { - Text("Min: \(suggestionSettings.customWordCountLowWords)") - } - Stepper( - value: customHighBinding, - in: SuggestionWordRange.minimumWord...SuggestionWordRange.maximumWord - ) { - Text("Max: \(suggestionSettings.customWordCountHighWords)") - } + LabeledContent("Minimum") { + wordCountField(value: customLowBinding) + } + LabeledContent("Maximum") { + wordCountField(value: customHighBinding) } Text("Token budget scales by your selected language. Multiple languages or a " + "language Cotabby doesn't recognize use the English ratio.") @@ -154,6 +149,27 @@ struct WritingPaneView: View { ) } + /// A compact "type or step" control: a right-aligned numeric field paired with up/down arrows, + /// both bound to the same clamping binding so a typed value and a stepped value land on the same + /// sensible range. Factored out so the Min and Max rows stay identical. The field uses the + /// `.number` format so it only commits a parsed integer on Return / focus loss, where the binding + /// clamps it — intermediate keystrokes never fight the clamp. + @ViewBuilder + private func wordCountField(value: Binding) -> some View { + HStack(spacing: 8) { + TextField("", value: value, format: .number) + .multilineTextAlignment(.trailing) + .frame(width: 56) + .textFieldStyle(.roundedBorder) + Stepper( + "", + value: value, + in: SuggestionWordRange.minimumWord...SuggestionWordRange.maximumWord + ) + .labelsHidden() + } + } + private var customLowBinding: Binding { Binding( get: { suggestionSettings.customWordCountLowWords }, From a26ec09f02a3020f382545b1c9a1d7bbd0fcc592 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:18:18 -0700 Subject: [PATCH 2/2] fix(a11y): give the custom Min/Max word-count fields VoiceOver labels Addresses Greptile feedback: LabeledContent's visible title isn't applied to the controls, so the TextField and Stepper were announced unnamed. Thread a label through wordCountField so Min and Max each get a distinct accessibility label. --- Cotabby/UI/Settings/Panes/WritingPaneView.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Cotabby/UI/Settings/Panes/WritingPaneView.swift b/Cotabby/UI/Settings/Panes/WritingPaneView.swift index 2915ec4d..b5192edf 100644 --- a/Cotabby/UI/Settings/Panes/WritingPaneView.swift +++ b/Cotabby/UI/Settings/Panes/WritingPaneView.swift @@ -32,10 +32,10 @@ struct WritingPaneView: View { // the curated picker stays the common path. if suggestionSettings.isUsingCustomWordCountRange { LabeledContent("Minimum") { - wordCountField(value: customLowBinding) + wordCountField(value: customLowBinding, label: "Minimum word count") } LabeledContent("Maximum") { - wordCountField(value: customHighBinding) + wordCountField(value: customHighBinding, label: "Maximum word count") } Text("Token budget scales by your selected language. Multiple languages or a " + "language Cotabby doesn't recognize use the English ratio.") @@ -153,20 +153,24 @@ struct WritingPaneView: View { /// both bound to the same clamping binding so a typed value and a stepped value land on the same /// sensible range. Factored out so the Min and Max rows stay identical. The field uses the /// `.number` format so it only commits a parsed integer on Return / focus loss, where the binding - /// clamps it — intermediate keystrokes never fight the clamp. + /// clamps it — intermediate keystrokes never fight the clamp. `label` is the spoken VoiceOver + /// name: `LabeledContent`'s visible title is not applied to the controls themselves, so the field + /// and stepper carry it explicitly (otherwise VoiceOver announces them unnamed). @ViewBuilder - private func wordCountField(value: Binding) -> some View { + private func wordCountField(value: Binding, label: String) -> some View { HStack(spacing: 8) { TextField("", value: value, format: .number) .multilineTextAlignment(.trailing) .frame(width: 56) .textFieldStyle(.roundedBorder) + .accessibilityLabel(label) Stepper( "", value: value, in: SuggestionWordRange.minimumWord...SuggestionWordRange.maximumWord ) .labelsHidden() + .accessibilityLabel(label) } }