Skip to content

fix: show red border for invalid history size input#1328

Open
claudeaceae wants to merge 2 commits intop0deje:masterfrom
claudeaceae:fix/history-size-validation
Open

fix: show red border for invalid history size input#1328
claudeaceae wants to merge 2 commits intop0deje:masterfrom
claudeaceae:fix/history-size-validation

Conversation

@claudeaceae
Copy link
Copy Markdown

Summary

  • Replaces the NumberFormatter-based TextField with a text-based TextField that validates input in real-time
  • Shows a red border when the entered value is outside the valid range (1–999) or non-numeric
  • On submit (Return key), invalid values revert to the last valid value
  • Stepper updates sync back to the text field

This addresses the silent failure where typing a value above 999 appeared to be accepted but was actually ignored.

Fixes #1292

Test plan

  • Type a value above 999 → red border appears
  • Type a non-numeric value → red border appears
  • Type a valid value (1–999) → no red border, value is saved
  • Use stepper → text field updates correctly
  • Press Return with invalid value → reverts to last valid value
  • Close and reopen settings → value persists correctly

🤖 Generated with Claude Code

Replace the NumberFormatter-based TextField with a text-based one that
validates input in real-time and shows a red border when the value is
outside the valid range (1-999). On submit (Return key), invalid values
revert to the last valid value.

Fixes p0deje#1292

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@prabhavagrawal7 prabhavagrawal7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some suggestions for cleaner code.

@State private var viewModel = ViewModel()
@State private var storageSize = Storage.shared.size
@State private var sizeText = ""
@State private var isSizeValid = true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

much better to use isSizeValid as a method to perform checks.

Suggested change
@State private var isSizeValid = true
private func isSizeValid() -> Bool {
if sizeText.isEmpty { return true }
guard let value = Int(sizeText) else { return false }
return Self.sizeRange.contains(value)
}

Stepper("", value: $size, in: 1...999)
.border(isSizeValid ? Color.clear : Color.red)
.onAppear { sizeText = "\(size)" }
.onChange(of: sizeText) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the redundent logic in onChange and onSubmit or atleast reuse them by creating another method.

@claudeaceae
Copy link
Copy Markdown
Author

Thanks for the review suggestions @prabhavagrawal7!

On converting isSizeValid to a computed method — I considered this, but isSizeValid is used as reactive @State that drives the border color via SwiftUI's view diffing. A computed method would work for initial evaluation, but the current approach lets us handle the edge cases cleanly (e.g., empty text during typing should show no border, and onSubmit resets validity when reverting to the last good value). That said, I'm open to the maintainer's preference here.

On the duplicated validation — good catch. I can extract a validateSize() helper to share the Int(sizeText) + range check between onChange and onSubmit. Will wait to see if the maintainer wants any changes before pushing an update though, since the two handlers do intentionally differ in behavior (onChange updates live, onSubmit reverts invalid input).

@prabhavagrawal7
Copy link
Copy Markdown

prabhavagrawal7 commented Feb 15, 2026

Let me share the snippet for much cleaner approach, where the states will be well defined:

  @State private var viewModel = ViewModel()
  @State private var storageSize = Storage.shared.size
  @State private var sizeText = ""

  private static let sizeRange = 1...999
  
  private func isValidSize() -> Bool {
    if sizeText.isEmpty { return true }
    guard let value = Int(sizeText) else { return false }
    return Self.sizeRange.contains(value)
  }

  var body: some View {
    Settings.Container(contentWidth: 450) {
      Settings.Section(
        bottomDivider: true,
        label: { Text("Save", tableName: "StorageSettings") }
      ) {
        Toggle(
          isOn: $viewModel.saveFiles,
          label: { Text("Files", tableName: "StorageSettings") }
        )
        Toggle(
          isOn: $viewModel.saveImages,
          label: { Text("Images", tableName: "StorageSettings") }
        )
        Toggle(
          isOn: $viewModel.saveText,
          label: { Text("Text", tableName: "StorageSettings") }
        )
        Text("SaveDescription", tableName: "StorageSettings")
          .controlSize(.small)
          .foregroundStyle(.gray)
      }

      Settings.Section(label: { Text("Size", tableName: "StorageSettings") }) {
        HStack {
          TextField("", text: $sizeText)
            .frame(width: 80)
            .help(Text("SizeTooltip", tableName: "StorageSettings"))
            .border(isValidSize() ? Color.clear : Color.red)
            .onAppear { sizeText = "\(size)" }
            .onChange(of: sizeText) {
              if isValidSize() {
                size = Int(sizeText) ?? size
              }
            }
            .onSubmit {
              if isValidSize(), let newSize = Int(sizeText) {
                size = newSize
              }
            }
          Stepper("", value: $size, in: Self.sizeRange)
            .labelsHidden()
            .onChange(of: size) {
              sizeText = "\(size)"
            }
          Text(storageSize)
            .controlSize(.small)
            .foregroundStyle(.gray)
            .help(Text("CurrentSizeTooltip", tableName: "StorageSettings"))
            .onAppear {
              storageSize = Storage.shared.size
            }
        }
      }

      Settings.Section(label: { Text("SortBy", tableName: "StorageSettings") }) {
        Picker("", selection: $sortBy) {
          ForEach(Sorter.By.allCases) { mode in
            Text(mode.description)
          }
        }
        .labelsHidden()
        .frame(width: 160, alignment: .leading)
        .help(Text("SortByTooltip", tableName: "StorageSettings"))
      }
    }
  }
}

@claudeaceae
Copy link
Copy Markdown
Author

@prabhavagrawal7 thanks for the detailed snippet! The computed isValidSize() approach is clean — I appreciate you taking the time to work it out.

You're right that this eliminates the explicit state tracking. One thing I want to preserve is the revert behavior on submit — when the user presses Enter with invalid input, the field should snap back to the last good value (so the red border resolves). The current code does sizeText = "\(size)" in the else branch of onSubmit for that. Your snippet's onSubmit silently ignores invalid input, which would leave the red border showing after Enter.

That said, I'll hold off on changes until the maintainer weighs in — they may have their own preferences on the approach. No point iterating between us if they want something different entirely.

@weisJ
Copy link
Copy Markdown
Collaborator

weisJ commented Feb 16, 2026

I like the approach of @prabhavagrawal7. You can restore the text content on onSubmit if the size is invalid.

Replace explicit state tracking with a computed method per reviewer
suggestion. Keeps revert-on-submit behavior for invalid input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claudeaceae
Copy link
Copy Markdown
Author

Thanks @weisJ! Pushed an update adopting the computed isValidSize() approach. The onSubmit handler now reverts to the last good value when invalid, so the red border clears on Enter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setting history size fails silently if above 999

3 participants