mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
@@ -69,24 +69,6 @@ enum AppLanguage: String, CaseIterable, Identifiable {
|
||||
resolved == .arabic ? .rightToLeft : .leftToRight
|
||||
}
|
||||
|
||||
/// Numbers follow the chosen UI language rather than the device region: someone who picked
|
||||
/// German expects 1,5x whatever their region says. System means whatever iOS is already doing.
|
||||
var numberLocale: Locale {
|
||||
guard self != .system else { return .autoupdatingCurrent }
|
||||
switch resolved {
|
||||
case .simplifiedChinese: return Locale(identifier: "zh_Hans")
|
||||
case .arabic: return Locale(identifier: "ar")
|
||||
case .spanish: return Locale(identifier: "es")
|
||||
case .french: return Locale(identifier: "fr")
|
||||
case .german: return Locale(identifier: "de")
|
||||
case .italian: return Locale(identifier: "it")
|
||||
case .portuguese: return Locale(identifier: "pt")
|
||||
case .japanese: return Locale(identifier: "ja")
|
||||
case .korean: return Locale(identifier: "ko")
|
||||
case .system, .english: return Locale(identifier: "en_US_POSIX")
|
||||
}
|
||||
}
|
||||
|
||||
func localized(_ key: String) -> String {
|
||||
guard let translated = Self.translations[resolved]?[key] else {
|
||||
return Self.commonTranslations[resolved]?[key] ?? Self.uiSupplementTranslations[resolved]?[key] ?? key
|
||||
|
||||
@@ -12,8 +12,4 @@ extension SettingsStore {
|
||||
var localizedLayoutDirection: LayoutDirection {
|
||||
appLanguage.layoutDirection
|
||||
}
|
||||
|
||||
var numberLocale: Locale {
|
||||
appLanguage.numberLocale
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
// NumberRow.swift — the one numeric control: label, tappable readout, slider between its bounds.
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// How a number is written down. A value rather than a closure, so the unit lives in one
|
||||
/// translatable template instead of being spelled " ms" at every call site, and so the rounding
|
||||
/// happens here once instead of being truncated in some rows and rounded in others.
|
||||
struct NumberFormat {
|
||||
/// Template with one %@ for the digits. Substituted by hand rather than through
|
||||
/// String(format:), because a stray %s in a hand written translation would crash.
|
||||
let unit: String
|
||||
let decimals: Int
|
||||
/// Shown = stored * scale. 100 is what turns a 0...1 opacity into a percentage.
|
||||
let scale: Double
|
||||
let showsSign: Bool
|
||||
/// Set by `opaque`, for rows still handing us a readout they built themselves.
|
||||
let literal: String?
|
||||
|
||||
init(unit: String = "", decimals: Int = 0, scale: Double = 1,
|
||||
showsSign: Bool = false, literal: String? = nil) {
|
||||
self.unit = unit
|
||||
self.decimals = decimals
|
||||
self.scale = scale
|
||||
self.showsSign = showsSign
|
||||
self.literal = literal
|
||||
}
|
||||
|
||||
static let plain = NumberFormat()
|
||||
/// Already stored as a percentage: volume 0...150, shade boost 1...100.
|
||||
static let percent = NumberFormat(unit: "%@%")
|
||||
/// Stored 0...1 and shown as a percentage.
|
||||
static let unitPercent = NumberFormat(unit: "%@%", scale: 100)
|
||||
static let milliseconds = NumberFormat(unit: "%@ ms")
|
||||
static let seconds = NumberFormat(unit: "%@ s", decimals: 2)
|
||||
static let points = NumberFormat(unit: "%@ pt")
|
||||
static let framesPerSecond = NumberFormat(unit: "%@ FPS")
|
||||
static let taps = NumberFormat(unit: "%@ taps")
|
||||
static let multiplier = NumberFormat(unit: "%@x", decimals: 2)
|
||||
static let degrees = NumberFormat(unit: "%@°", showsSign: true)
|
||||
static let radiansPerSecond = NumberFormat(unit: "%@ rad/s", decimals: 2)
|
||||
|
||||
/// A row built around a string it formatted itself. It still slides, clamps, brackets and
|
||||
/// announces itself, it just cannot label its own bounds, so it does not.
|
||||
static func opaque(_ text: String) -> NumberFormat {
|
||||
NumberFormat(literal: text)
|
||||
}
|
||||
|
||||
func decimals(_ count: Int) -> NumberFormat {
|
||||
NumberFormat(unit: unit, decimals: count, scale: scale, showsSign: showsSign)
|
||||
}
|
||||
|
||||
var signed: NumberFormat {
|
||||
NumberFormat(unit: unit, decimals: decimals, scale: scale, showsSign: true)
|
||||
}
|
||||
|
||||
var labelsBounds: Bool { literal == nil }
|
||||
|
||||
/// Digits only. This is what the keyboard edits and what goes into the unit template.
|
||||
func digits(_ value: Double, locale: Locale) -> String {
|
||||
(value * scale).formatted(
|
||||
.number
|
||||
.precision(.fractionLength(decimals))
|
||||
.rounded(rule: .toNearestOrAwayFromZero)
|
||||
.sign(strategy: showsSign ? .always(includingZero: false) : .automatic)
|
||||
.grouping(.never)
|
||||
.locale(locale)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func text(_ value: Double, settings: SettingsStore) -> String {
|
||||
if let literal { return literal }
|
||||
let number = digits(value, locale: settings.numberLocale)
|
||||
guard !unit.isEmpty else { return number }
|
||||
return settings.localized(unit).replacingOccurrences(of: "%@", with: number)
|
||||
}
|
||||
|
||||
/// Undoes `scale`, so typing 70 into a 0...1 opacity row gets you 0.7.
|
||||
func parse(_ text: String, locale: Locale) -> Double? {
|
||||
let cleaned = text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: locale.groupingSeparator ?? ",", with: "")
|
||||
.replacingOccurrences(of: locale.decimalSeparator ?? ".", with: ".")
|
||||
guard let shown = Double(cleaned), shown.isFinite else { return nil }
|
||||
return shown / scale
|
||||
}
|
||||
}
|
||||
|
||||
/// Set once per surface, so 275 call sites do not each carry fonts and padding.
|
||||
struct NumberRowStyle {
|
||||
var titleFont: Font = .body
|
||||
var valueFont: Font = .callout.monospacedDigit()
|
||||
var boundsFont: Font = .caption.monospacedDigit()
|
||||
var spacing: CGFloat = 8
|
||||
var verticalPadding: CGFloat = 4
|
||||
var allowsTypedEntry = true
|
||||
|
||||
static let form = NumberRowStyle()
|
||||
static let compact = NumberRowStyle(
|
||||
titleFont: .caption.weight(.semibold),
|
||||
valueFont: .caption.monospacedDigit(),
|
||||
boundsFont: .caption2.monospacedDigit(),
|
||||
spacing: 6,
|
||||
verticalPadding: 0
|
||||
)
|
||||
}
|
||||
|
||||
/// Live reporting for a surface that draws its own readout while a slider moves. Only the
|
||||
/// dynamic background editor supplies one.
|
||||
///
|
||||
/// Unchecked because it has to be an environment default and only ever runs from a view body,
|
||||
/// which is already on the main actor.
|
||||
struct NumberRowActivity: @unchecked Sendable {
|
||||
let update: (_ title: String, _ value: String, _ isEditing: Bool) -> Void
|
||||
}
|
||||
|
||||
/// The one trailing affordance the header has room for. Reset uses it; the per-game row uses it
|
||||
/// to go back to the global value.
|
||||
struct NumberRowAccessory {
|
||||
let systemImage: String
|
||||
/// Localizable, one %@ for the row title. VoiceOver only, the button itself is a glyph.
|
||||
let label: String
|
||||
let isVisible: Bool
|
||||
let action: () -> Void
|
||||
}
|
||||
|
||||
private struct NumberRowStyleKey: EnvironmentKey {
|
||||
static let defaultValue = NumberRowStyle.form
|
||||
}
|
||||
|
||||
private struct NumberRowActivityKey: EnvironmentKey {
|
||||
static let defaultValue = NumberRowActivity { _, _, _ in }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var numberRowStyle: NumberRowStyle {
|
||||
get { self[NumberRowStyleKey.self] }
|
||||
set { self[NumberRowStyleKey.self] = newValue }
|
||||
}
|
||||
|
||||
var numberRowActivity: NumberRowActivity {
|
||||
get { self[NumberRowActivityKey.self] }
|
||||
set { self[NumberRowActivityKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func numberRowStyle(_ style: NumberRowStyle) -> some View {
|
||||
environment(\.numberRowStyle, style)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every numeric setting in the app. Replaces IntSliderRow, ClampedIntField, the two private
|
||||
/// slider structs and the dozen hand rolled VStack-plus-Slider rows that each formatted, clamped
|
||||
/// and mostly failed to label themselves differently.
|
||||
///
|
||||
/// Colours are semantic on purpose. The global Form is light or dark and the per-game panel and
|
||||
/// background editor both force dark, so .primary and .secondary land correctly in all three and
|
||||
/// OverlayTheme never has to be reached for.
|
||||
struct NumberRow: View {
|
||||
enum Style {
|
||||
case slider
|
||||
case stepper
|
||||
case field
|
||||
}
|
||||
|
||||
private let title: String
|
||||
private let icon: String?
|
||||
private let store: Binding<Double>
|
||||
private let range: ClosedRange<Double>
|
||||
private let step: Double?
|
||||
private let format: NumberFormat
|
||||
private let style: Style
|
||||
private let accessory: NumberRowAccessory?
|
||||
private let hint: String?
|
||||
private let settings: SettingsStore
|
||||
|
||||
@Environment(\.numberRowStyle) private var rowStyle
|
||||
@Environment(\.numberRowActivity) private var activity
|
||||
@State private var isDragging = false
|
||||
@State private var isTyping = false
|
||||
@State private var draft = ""
|
||||
@FocusState private var fieldFocused: Bool
|
||||
|
||||
init(_ title: String,
|
||||
value: Binding<Double>,
|
||||
in range: ClosedRange<Double>,
|
||||
format: NumberFormat = .plain,
|
||||
step: Double? = nil,
|
||||
icon: String? = nil,
|
||||
style: Style = .slider,
|
||||
default defaultValue: Double? = nil,
|
||||
accessory: NumberRowAccessory? = nil,
|
||||
hint: String? = nil,
|
||||
settings: SettingsStore) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.store = value
|
||||
self.range = range
|
||||
self.step = step
|
||||
self.format = format
|
||||
self.style = style
|
||||
self.hint = hint
|
||||
self.settings = settings
|
||||
// An explicit accessory wins; `default:` is sugar for the common one.
|
||||
let slack = (range.upperBound - range.lowerBound) * 1e-6
|
||||
self.accessory = accessory ?? defaultValue.map { fallback in
|
||||
NumberRowAccessory(
|
||||
systemImage: "arrow.counterclockwise",
|
||||
label: "Reset %@",
|
||||
isVisible: abs(value.wrappedValue - fallback) > slack,
|
||||
action: { value.wrappedValue = fallback }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init(_ title: String,
|
||||
value: Binding<Int>,
|
||||
in range: ClosedRange<Int>,
|
||||
format: NumberFormat = .plain,
|
||||
icon: String? = nil,
|
||||
style: Style = .slider,
|
||||
default defaultValue: Int? = nil,
|
||||
accessory: NumberRowAccessory? = nil,
|
||||
hint: String? = nil,
|
||||
settings: SettingsStore) {
|
||||
self.init(title,
|
||||
value: Binding(get: { Double(value.wrappedValue) },
|
||||
set: { value.wrappedValue = Int($0.rounded()) }),
|
||||
in: Double(range.lowerBound)...Double(range.upperBound),
|
||||
format: format,
|
||||
step: 1,
|
||||
icon: icon,
|
||||
style: style,
|
||||
default: defaultValue.map(Double.init),
|
||||
accessory: accessory,
|
||||
hint: hint,
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
init(_ title: String,
|
||||
value: Binding<Float>,
|
||||
in range: ClosedRange<Float>,
|
||||
format: NumberFormat = .plain,
|
||||
step: Double? = nil,
|
||||
icon: String? = nil,
|
||||
style: Style = .slider,
|
||||
default defaultValue: Float? = nil,
|
||||
accessory: NumberRowAccessory? = nil,
|
||||
hint: String? = nil,
|
||||
settings: SettingsStore) {
|
||||
self.init(title,
|
||||
value: Binding(get: { Double(value.wrappedValue) },
|
||||
set: { value.wrappedValue = Float($0) }),
|
||||
in: Double(range.lowerBound)...Double(range.upperBound),
|
||||
format: format,
|
||||
step: step,
|
||||
icon: icon,
|
||||
style: style,
|
||||
default: defaultValue.map(Double.init),
|
||||
accessory: accessory,
|
||||
hint: hint,
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(.vertical, rowStyle.verticalPadding)
|
||||
.onChange(of: store.wrappedValue) { _, _ in
|
||||
guard isDragging else { return }
|
||||
activity.update(localizedTitle, displayText, true)
|
||||
}
|
||||
.onChange(of: fieldFocused) { _, focused in
|
||||
if !focused { commit() }
|
||||
}
|
||||
.onDisappear(perform: release)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch style {
|
||||
case .slider:
|
||||
VStack(alignment: .leading, spacing: rowStyle.spacing) {
|
||||
header
|
||||
track
|
||||
}
|
||||
case .stepper:
|
||||
// One line by design. A stepper has no track to hang bounds off, and the ranges that
|
||||
// earn a stepper are short enough to read off the value.
|
||||
Stepper(value: clampedValue, in: range, step: step ?? 1) { header }
|
||||
case .field:
|
||||
header
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
label
|
||||
Spacer(minLength: 8)
|
||||
readout
|
||||
if let accessory, accessory.isVisible {
|
||||
accessoryButton(accessory)
|
||||
}
|
||||
}
|
||||
.font(rowStyle.titleFont)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var label: some View {
|
||||
if let icon {
|
||||
Label(localizedTitle, systemImage: icon)
|
||||
} else {
|
||||
Text(localizedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var track: some View {
|
||||
if range.lowerBound < range.upperBound {
|
||||
slider
|
||||
.accessibilityLabel(localizedTitle)
|
||||
.accessibilityValue(displayText)
|
||||
.modifier(OptionalHint(text: hint.map { settings.localized($0) }))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var slider: some View {
|
||||
if let step {
|
||||
Slider(value: clampedValue,
|
||||
in: range,
|
||||
step: step,
|
||||
label: { Text(localizedTitle) },
|
||||
minimumValueLabel: { bound(range.lowerBound) },
|
||||
maximumValueLabel: { bound(range.upperBound) },
|
||||
onEditingChanged: dragChanged)
|
||||
} else {
|
||||
Slider(value: clampedValue,
|
||||
in: range,
|
||||
label: { Text(localizedTitle) },
|
||||
minimumValueLabel: { bound(range.lowerBound) },
|
||||
maximumValueLabel: { bound(range.upperBound) },
|
||||
onEditingChanged: dragChanged)
|
||||
}
|
||||
}
|
||||
|
||||
/// Digits only. The unit is in the readout right above, in the same column, and repeating it
|
||||
/// costs about a third of the track on a compact row.
|
||||
@ViewBuilder
|
||||
private func bound(_ value: Double) -> some View {
|
||||
if format.labelsBounds {
|
||||
Text(format.digits(value, locale: settings.numberLocale))
|
||||
.font(rowStyle.boundsFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var readout: some View {
|
||||
if isTyping {
|
||||
TextField("", text: $draft)
|
||||
.focused($fieldFocused)
|
||||
.keyboardType(keyboardType)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { fieldFocused = false }
|
||||
.multilineTextAlignment(.trailing)
|
||||
.textFieldStyle(.plain)
|
||||
.font(rowStyle.valueFont)
|
||||
.frame(minWidth: 56, maxWidth: 132)
|
||||
.toolbar {
|
||||
// Declared inside the editing branch, so the bar can only come from the row
|
||||
// you are actually in rather than from all 275 at once.
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button(settings.localized("Done")) { fieldFocused = false }
|
||||
}
|
||||
}
|
||||
} else if rowStyle.allowsTypedEntry {
|
||||
Text(displayText)
|
||||
.font(rowStyle.valueFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: beginTyping)
|
||||
.accessibilityLabel(localizedTitle)
|
||||
.accessibilityValue(displayText)
|
||||
.accessibilityHint(settings.localized("Double tap to type a value."))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
} else {
|
||||
Text(displayText)
|
||||
.font(rowStyle.valueFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func accessoryButton(_ accessory: NumberRowAccessory) -> some View {
|
||||
Button(action: accessory.action) {
|
||||
Image(systemName: accessory.systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(
|
||||
settings.localized(accessory.label).replacingOccurrences(of: "%@", with: localizedTitle)
|
||||
)
|
||||
}
|
||||
|
||||
/// A hand edited INI hands us values outside the range and Slider does not cope with that, so
|
||||
/// read clamps as well as write.
|
||||
private var clampedValue: Binding<Double> {
|
||||
Binding(get: { clamped(store.wrappedValue) },
|
||||
set: { store.wrappedValue = clamped($0) })
|
||||
}
|
||||
|
||||
private func clamped(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return range.lowerBound }
|
||||
return min(max(value, range.lowerBound), range.upperBound)
|
||||
}
|
||||
|
||||
private var localizedTitle: String { settings.localized(title) }
|
||||
|
||||
private var displayText: String { format.text(clamped(store.wrappedValue), settings: settings) }
|
||||
|
||||
/// Baked in, because the two sliders that did this by hand could leave the counter raised for
|
||||
/// the rest of the session if the screen went away mid drag.
|
||||
private func dragChanged(_ editing: Bool) {
|
||||
guard editing != isDragging else { return }
|
||||
isDragging = editing
|
||||
if editing {
|
||||
settings.beginVisualSliderEdit()
|
||||
} else {
|
||||
settings.endVisualSliderEdit()
|
||||
}
|
||||
activity.update(localizedTitle, displayText, editing)
|
||||
}
|
||||
|
||||
private func release() {
|
||||
if isDragging {
|
||||
isDragging = false
|
||||
settings.endVisualSliderEdit()
|
||||
activity.update(localizedTitle, displayText, false)
|
||||
}
|
||||
if isTyping { commit() }
|
||||
}
|
||||
|
||||
/// Two steps on purpose. Focus cannot land on a field that does not exist yet, and setting
|
||||
/// both in one update is where the keyboard appears and immediately vanishes.
|
||||
private func beginTyping() {
|
||||
draft = format.digits(clamped(store.wrappedValue), locale: settings.numberLocale)
|
||||
isTyping = true
|
||||
DispatchQueue.main.async { fieldFocused = true }
|
||||
}
|
||||
|
||||
/// Nothing unparseable is written and nothing out of range is written; the row snaps back to
|
||||
/// what it had. The silence is the point, a settings row is not the place to argue about a typo.
|
||||
///
|
||||
/// No bracketing here. That exists to stop a reload per drag tick, and this is one write.
|
||||
private func commit() {
|
||||
isTyping = false
|
||||
guard let parsed = format.parse(draft, locale: settings.numberLocale) else { return }
|
||||
let next = clamped(parsed)
|
||||
guard next != store.wrappedValue else { return }
|
||||
store.wrappedValue = next
|
||||
}
|
||||
|
||||
/// decimalPad has no minus key, which is how the old typed field ended up on
|
||||
/// numbersAndPunctuation. Texture offsets, deadzones and gradient tilt all go negative.
|
||||
private var keyboardType: UIKeyboardType {
|
||||
if range.lowerBound < 0 { return .numbersAndPunctuation }
|
||||
return format.decimals == 0 ? .numberPad : .decimalPad
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty hint still announces a pause, so only apply it when there is one.
|
||||
private struct OptionalHint: ViewModifier {
|
||||
let text: String?
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if let text {
|
||||
content.accessibilityHint(text)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user