diff --git a/src/Shared/PresetsDisplay/TagKey.swift b/src/Shared/PresetsDisplay/TagKey.swift new file mode 100644 index 000000000..98f12a9ab --- /dev/null +++ b/src/Shared/PresetsDisplay/TagKey.swift @@ -0,0 +1,80 @@ +// +// TagKey.swift +// Go Map!! +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import UIKit + +/// OSM tag key helpers shared by the POI editor. +enum TagKey { + private static let exactNameLikeKeys: Set = ["name", "alt_name", "old_name"] + + /// Keys that carry human-readable names and should use the same keyboard traits as `name`. + static func isNameLike(_ key: String) -> Bool { + guard !key.isEmpty else { return false } + if exactNameLikeKeys.contains(key) { + return true + } + return key.hasPrefix("name:") + } + + static func autocapitalizationType(matchingNamePresetIn presets: [PresetDisplayKey]) + -> UITextAutocapitalizationType + { + presets.first(where: { $0.tagKey == "name" })?.autocapitalizationType ?? .words + } + + static func autocorrectType(matchingNamePresetIn presets: [PresetDisplayKey]) -> UITextAutocorrectionType { + presets.first(where: { $0.tagKey == "name" })?.autocorrectType ?? .no + } + + static func applyNameLikeTraits(to textField: UITextField, presets: [PresetDisplayKey]) { + textField.autocapitalizationType = autocapitalizationType(matchingNamePresetIn: presets) + textField.autocorrectionType = autocorrectType(matchingNamePresetIn: presets) + textField.spellCheckingType = textField.autocorrectionType == .no ? .no : .default + } + + static func applyNameLikeTraits(to textView: UITextView, presets: [PresetDisplayKey]) { + textView.autocapitalizationType = autocapitalizationType(matchingNamePresetIn: presets) + textView.autocorrectionType = autocorrectType(matchingNamePresetIn: presets) + textView.spellCheckingType = textView.autocorrectionType == .no ? .no : .default + } + + /// Configure a free-form key/value value field (All Tags, Common Tags extras). + static func configureKeyValueField(_ textField: UITextField, key: String, presets: [PresetDisplayKey]) { + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.spellCheckingType = .no + if isNameLike(key) { + applyNameLikeTraits(to: textField, presets: presets) + } + } + + /// Configure a preset-driven value field, overriding `.none` for name-like keys. + static func configurePresetValueField(_ textField: UITextField, + key: String, + preset: PresetDisplayKey, + presets: [PresetDisplayKey]) + { + textField.autocapitalizationType = preset.autocapitalizationType + textField.autocorrectionType = preset.autocorrectType + textField.spellCheckingType = preset.autocorrectType == .no ? .no : .default + if isNameLike(key), preset.autocapitalizationType == .none { + applyNameLikeTraits(to: textField, presets: presets) + } + } + + /// Apply name-like traits when editing, if schema did not already specify capitalization. + static func applyNameLikeOverrideIfNeeded(to textField: UITextField, + key: String, + preset: PresetDisplayKey?, + presets: [PresetDisplayKey]) + { + guard isNameLike(key) else { return } + if preset == nil || preset?.autocapitalizationType == .none { + applyNameLikeTraits(to: textField, presets: presets) + } + } +} diff --git a/src/iOS/Go Map!!.xcodeproj/project.pbxproj b/src/iOS/Go Map!!.xcodeproj/project.pbxproj index 62c74711e..ff6c58d36 100644 --- a/src/iOS/Go Map!!.xcodeproj/project.pbxproj +++ b/src/iOS/Go Map!!.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 021BC2222D7625FB004631C5 /* Panoramax.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 021BC2212D7625FB004631C5 /* Panoramax.storyboard */; }; 021C6AB3168768C800FB17B0 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 021C6AB2168768C800FB17B0 /* MessageUI.framework */; }; 021FADBB258591D000F6E1C0 /* PresetDisplayKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021FADBA258591D000F6E1C0 /* PresetDisplayKey.swift */; }; + C8E2A0012F5C000100000001 /* TagKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8E2A0022F5C000100000001 /* TagKey.swift */; }; 021FADC0258594EE00F6E1C0 /* PresetDisplayValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021FADBF258594EE00F6E1C0 /* PresetDisplayValue.swift */; }; 021FADC52585951E00F6E1C0 /* PresetDisplayGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021FADC42585951E00F6E1C0 /* PresetDisplayGroup.swift */; }; 021FADCD25873C8200F6E1C0 /* PresetsDatabase+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021FADCC25873C8200F6E1C0 /* PresetsDatabase+Display.swift */; }; @@ -238,6 +239,7 @@ 647F46CE2253EA4C00CEC482 /* MeasureDirectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F46CD2253EA4C00CEC482 /* MeasureDirectionViewModel.swift */; }; 647F46D12253F08200CEC482 /* HeadingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F46D02253F08200CEC482 /* HeadingProvider.swift */; }; 64C072FA226227D500598078 /* PresetKeyTagCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C072F9226227D500598078 /* PresetKeyTagCase.swift */; }; + C8E2A0032F5C000100000001 /* TagKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8E2A0042F5C000100000001 /* TagKeyTests.swift */; }; 64D74BF32253DF49004FFD20 /* DirectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D74BF12253DF49004FFD20 /* DirectionViewController.swift */; }; 64E21EB522651C06004605D7 /* OSMMapDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E21EB422651C06004605D7 /* OSMMapDataTestCase.swift */; }; 64E21EB822651F2D004605D7 /* XCTestCase+UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E21EB722651F2D004605D7 /* XCTestCase+UserDefaults.swift */; }; @@ -392,6 +394,7 @@ 021BC2212D7625FB004631C5 /* Panoramax.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Panoramax.storyboard; sourceTree = ""; }; 021C6AB2168768C800FB17B0 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; 021FADBA258591D000F6E1C0 /* PresetDisplayKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDisplayKey.swift; sourceTree = ""; }; + C8E2A0022F5C000100000001 /* TagKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagKey.swift; sourceTree = ""; }; 021FADBF258594EE00F6E1C0 /* PresetDisplayValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDisplayValue.swift; sourceTree = ""; }; 021FADC42585951E00F6E1C0 /* PresetDisplayGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDisplayGroup.swift; sourceTree = ""; }; 021FADCC25873C8200F6E1C0 /* PresetsDatabase+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PresetsDatabase+Display.swift"; sourceTree = ""; }; @@ -600,6 +603,7 @@ 647F46CD2253EA4C00CEC482 /* MeasureDirectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasureDirectionViewModel.swift; sourceTree = ""; }; 647F46D02253F08200CEC482 /* HeadingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingProvider.swift; sourceTree = ""; }; 64C072F9226227D500598078 /* PresetKeyTagCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetKeyTagCase.swift; sourceTree = ""; }; + C8E2A0042F5C000100000001 /* TagKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagKeyTests.swift; sourceTree = ""; }; 64D74BF12253DF49004FFD20 /* DirectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionViewController.swift; sourceTree = ""; }; 64E21EB422651C06004605D7 /* OSMMapDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMMapDataTestCase.swift; sourceTree = ""; }; 64E21EB722651F2D004605D7 /* XCTestCase+UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+UserDefaults.swift"; sourceTree = ""; }; @@ -1088,6 +1092,7 @@ 021FADD625873D0100F6E1C0 /* PresetDisplayForFeature.swift */, 021FADC42585951E00F6E1C0 /* PresetDisplayGroup.swift */, 021FADBA258591D000F6E1C0 /* PresetDisplayKey.swift */, + C8E2A0022F5C000100000001 /* TagKey.swift */, 021FADD125873CC000F6E1C0 /* PresetDisplayKeyUserDefined.swift */, 021FADBF258594EE00F6E1C0 /* PresetDisplayValue.swift */, 021FADCC25873C8200F6E1C0 /* PresetsDatabase+Display.swift */, @@ -1185,6 +1190,7 @@ children = ( 64E21EB622651F0D004605D7 /* Helpers */, 64C072F9226227D500598078 /* PresetKeyTagCase.swift */, + C8E2A0042F5C000100000001 /* TagKeyTests.swift */, 64348CFA225E867800ADE7FB /* MeasureDirectionViewModelTestCase.swift */, 64348CF6225E867800ADE7FB /* Mocks */, 64348D02225E8E4300ADE7FB /* OsmNode_DirectionTestCase.swift */, @@ -1640,6 +1646,7 @@ 02CE3CE929919FC400DDACE0 /* MapPinButton.swift in Sources */, EDDBA55326130287001E7D5C /* GpxViewController.swift in Sources */, 021FADBB258591D000F6E1C0 /* PresetDisplayKey.swift in Sources */, + C8E2A0012F5C000100000001 /* TagKey.swift in Sources */, C381C30B263FE518003142BA /* PushPinView.swift in Sources */, 02CB2407265DB7CB00835F32 /* LevenshteinDistance.swift in Sources */, C3004F54263A99DD006BF313 /* POIAttributesViewController.swift in Sources */, @@ -1807,6 +1814,7 @@ files = ( 64348CFE225E867800ADE7FB /* MeasureDirectionViewModelTestCase.swift in Sources */, 64C072FA226227D500598078 /* PresetKeyTagCase.swift in Sources */, + C8E2A0032F5C000100000001 /* TagKeyTests.swift in Sources */, 64348CFC225E867800ADE7FB /* MeasureDirectionViewModelDelegateMock.swift in Sources */, 64348CED225E7CD900ADE7FB /* GoMapTests.swift in Sources */, 64305F7423E723B200232BB9 /* LocationURLParserTestCase.swift in Sources */, diff --git a/src/iOS/GoMapTests/TagKeyTests.swift b/src/iOS/GoMapTests/TagKeyTests.swift new file mode 100644 index 000000000..d45fd29bc --- /dev/null +++ b/src/iOS/GoMapTests/TagKeyTests.swift @@ -0,0 +1,74 @@ +// +// TagKeyTests.swift +// GoMapTests +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +@testable import Go_Map__ +import XCTest + +class TagKeyTests: XCTestCase { + func testIsNameLikePositiveCases() { + let positive = ["name", "name:en", "name:zh-Hans", "alt_name", "old_name"] + for key in positive { + XCTAssertTrue(TagKey.isNameLike(key), "expected name-like: \(key)") + } + } + + func testIsNameLikeNegativeCases() { + let negative = ["namesake", "name_source", ""] + for key in negative { + XCTAssertFalse(TagKey.isNameLike(key), "expected not name-like: \"\(key)\"") + } + } + + func testConfigureKeyValueFieldAppliesNameTraits() { + let field = UITextField() + let namePreset = PresetDisplayKey(name: "Name", + type: .text, + tagKey: "name", + defaultValue: nil, + placeholder: nil, + keyboard: .default, + capitalize: .words, + autocorrect: .no, + presetValues: nil) + TagKey.configureKeyValueField(field, key: "name:de", presets: [namePreset]) + XCTAssertEqual(field.autocapitalizationType, .words) + XCTAssertEqual(field.autocorrectionType, .no) + XCTAssertEqual(field.spellCheckingType, .no) + } + + func testConfigureKeyValueFieldResetsNonNameKeys() { + let field = UITextField() + field.autocapitalizationType = .words + TagKey.configureKeyValueField(field, key: "ref", presets: []) + XCTAssertEqual(field.autocapitalizationType, .none) + XCTAssertEqual(field.autocorrectionType, .no) + } + + func testConfigurePresetValueFieldOverridesNoneForNameLikeKeys() { + let field = UITextField() + let namePreset = PresetDisplayKey(name: "Name", + type: .text, + tagKey: "name", + defaultValue: nil, + placeholder: nil, + keyboard: .default, + capitalize: .words, + autocorrect: .no, + presetValues: nil) + let altPreset = PresetDisplayKey(name: "Alt Name", + type: .text, + tagKey: "alt_name", + defaultValue: nil, + placeholder: nil, + keyboard: .default, + capitalize: .none, + autocorrect: .no, + presetValues: nil) + TagKey.configurePresetValueField(field, key: "alt_name", preset: altPreset, presets: [namePreset]) + XCTAssertEqual(field.autocapitalizationType, .words) + } +} diff --git a/src/iOS/POI/KeyValueTableCell.swift b/src/iOS/POI/KeyValueTableCell.swift index 8a195652e..455a5b9ac 100644 --- a/src/iOS/POI/KeyValueTableCell.swift +++ b/src/iOS/POI/KeyValueTableCell.swift @@ -166,9 +166,15 @@ class KeyValueTableCell: TextPairTableCell, PresetValueTextFieldOwner, UITextFie func selectTextViewFor(key: String) { // set text formatting options for text field - if let preset = keyValueCellOwner?.allPresetKeys.first(where: { key == $0.tagKey }) { + let presets = keyValueCellOwner?.allPresetKeys ?? [] + if let preset = presets.first(where: { key == $0.tagKey }) { if preset.type == .textarea { useTextView() + if TagKey.isNameLike(key) { + if let textView = textView { + TagKey.applyNameLikeTraits(to: textView, presets: presets) + } + } } else { useTextField() } diff --git a/src/iOS/POI/POIAllTagsViewController.swift b/src/iOS/POI/POIAllTagsViewController.swift index 5066327d7..95670b117 100644 --- a/src/iOS/POI/POIAllTagsViewController.swift +++ b/src/iOS/POI/POIAllTagsViewController.swift @@ -322,6 +322,7 @@ class POIAllTagsViewController: UITableViewController, POIFeaturePickerDelegate, cell.text1.autocapitalizationType = .none cell.text1.spellCheckingType = .no cell.text2.defaultInputAccessoryView = prevNextToolbar + TagKey.configureKeyValueField(cell.text2, key: kv.k, presets: allPresetKeys) cell.isSet.backgroundColor = kv.k == "" || kv.v == "" ? nil : UIColor.systemBlue return cell diff --git a/src/iOS/POI/POICommonTagsViewController.swift b/src/iOS/POI/POICommonTagsViewController.swift index cd25d81d5..ec1afff3c 100644 --- a/src/iOS/POI/POICommonTagsViewController.swift +++ b/src/iOS/POI/POICommonTagsViewController.swift @@ -341,6 +341,7 @@ class POICommonTagsViewController: UITableViewController, UITextFieldDelegate, U cell.text1?.text = extraTags[indexPath.row].k cell.text2?.text = extraTags[indexPath.row].v cell.text2.key = cell.text1?.text ?? "" + TagKey.configureKeyValueField(cell.text2, key: cell.text2.key, presets: allPresetKeys) cell.isSet.backgroundColor = cell.value == "" ? nil : Self.isSetHighlight return cell } @@ -434,7 +435,10 @@ class POICommonTagsViewController: UITableViewController, UITextFieldDelegate, U cell.valueField.presetKey = presetKey cell.presetKey = .key(presetKey) cell.valueField.keyboardType = presetKey.keyboardType - cell.valueField.autocapitalizationType = presetKey.autocapitalizationType + TagKey.configurePresetValueField(cell.valueField, + key: presetKey.tagKey, + preset: presetKey, + presets: allPresetKeys) cell.valueField.removeTarget(self, action: nil, for: .allEvents) cell.valueField.addTarget(self, action: #selector(textFieldReturn(_:)), for: .editingDidEndOnExit) diff --git a/src/iOS/POI/PresetValueTextField.swift b/src/iOS/POI/PresetValueTextField.swift index d7d5a8e2b..3347db77b 100644 --- a/src/iOS/POI/PresetValueTextField.swift +++ b/src/iOS/POI/PresetValueTextField.swift @@ -83,7 +83,10 @@ class PresetValueTextField: AutocompleteTextField, PanoramaxDelegate { { inputAccessoryView = TelephoneToolbar(forTextField: self, frame: frame) } + + TagKey.applyNameLikeOverrideIfNeeded(to: self, key: key, preset: preset, presets: owner.allPresetKeys) } else { + TagKey.applyNameLikeOverrideIfNeeded(to: self, key: key, preset: nil, presets: owner.allPresetKeys) switch key { case "note", "comment", "description", "fixme", "inscription", "source": autocapitalizationType = .sentences