Skip to content

Commit effa2ff

Browse files
authored
Add per-control size sliders and dismiss editor panels on outside taps (#553)
Co-authored-by: intraducine <265588704+intraducine@users.noreply.github.com>
1 parent 2927f0e commit effa2ff

4 files changed

Lines changed: 140 additions & 8 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ jobs:
3535
python3 tests/host/check-clean-bootstrap.py
3636
python3 tests/host/check-app-link-inputs.py
3737
python3 tests/host/check-runtime-components.py
38+
- name: Check touch control layouts and editor
39+
run: python3 tests/host/check-control-presets.py
3840
- name: Check Steam connection recovery
3941
if: hashFiles('tests/host/check-steam-cm-transport.py') != ''
4042
run: python3 tests/host/check-steam-cm-transport.py

‎app/Madeira/ContentView.swift‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4297,6 +4297,10 @@ struct TouchControlsOverlay: View {
42974297
// The library between sessions (the window came up for a start that did not
42984298
// happen) draws nothing over the library's own screens.
42994299
if landscape && !(library.enabled && !session && !m.editing) {
4300+
if m.editing {
4301+
Color.clear.contentShape(editorCanvas(geo.size))
4302+
.onTapGesture { m.selected = nil; zones.selected = nil }
4303+
}
43004304
if (m.visible || m.editing) && !library.blocksGameplayTouch {
43014305
if GestureZonesModel.enabled {
43024306
GestureZonesLayer(screen: geo.size, editing: m.editing)
@@ -4329,6 +4333,22 @@ struct TouchControlsOverlay: View {
43294333
.persistentSystemOverlays(library.current != nil ? .hidden : .automatic)
43304334
}
43314335

4336+
/// The dismissal gesture must never take a control's drag on iOS 26.
4337+
private func editorCanvas(_ screen: CGSize) -> Path {
4338+
var area = Path(CGRect(origin: .zero, size: screen))
4339+
for control in m.controls {
4340+
let size = TouchControlsModel.size(control)
4341+
let frame = CGRect(x: CGFloat(control.nx) * screen.width - size.width / 2,
4342+
y: CGFloat(control.ny) * screen.height - size.height / 2,
4343+
width: size.width, height: size.height)
4344+
area = area.subtracting(Path(frame))
4345+
}
4346+
if GestureZonesModel.enabled {
4347+
for zone in zones.zones { area = area.subtracting(Path(zone.rect(in: screen))) }
4348+
}
4349+
return area
4350+
}
4351+
43324352
/// Every control in ONE GlassEffectContainer: on iOS 26 the system merges
43334353
/// glass shapes that come within `spacing` of each other, so a D-pad cross
43344354
/// or a face diamond pinched tight reads as one piece of glass and a button
@@ -4788,6 +4808,7 @@ struct MappingPanel: View {
47884808
let screen: CGSize
47894809
@ObservedObject private var m = TouchControlsModel.shared
47904810
@State private var tab = 0 // 0 keyboard, 1 controller
4811+
@State private var resizeDiameter: CGFloat?
47914812

47924813

47934814
var body: some View {
@@ -4806,6 +4827,31 @@ struct MappingPanel: View {
48064827
.buttonStyle(.plain)
48074828
.accessibilityLabel(control.locked == true ? "Unlock control" : "Lock control")
48084829
}
4830+
HStack(spacing: 8) {
4831+
Text("Size").font(.system(size: 12))
4832+
Slider(value: Binding(get: { control.scale }, set: { value in
4833+
if let i = m.index(of: control.id), m.controls[i].locked != true {
4834+
m.controls[i].scale = value
4835+
}
4836+
}), in: 0.5...3.0, onEditingChanged: { resizing in
4837+
if resizing {
4838+
if resizeDiameter == nil { resizeDiameter = TouchControlsModel.diameter(control) }
4839+
} else if resizeDiameter != nil {
4840+
// Let the slider finish tracking before the panel moves.
4841+
DispatchQueue.main.async { resizeDiameter = nil }
4842+
}
4843+
})
4844+
.id(control.id)
4845+
.disabled(control.locked == true)
4846+
.accessibilityLabel("Control size")
4847+
.accessibilityValue("\(Int(control.scale * 100)) percent")
4848+
Text("\(Int(control.scale * 100))%")
4849+
.font(.system(size: 12).monospacedDigit())
4850+
.frame(width: 42, alignment: .trailing)
4851+
}
4852+
.padding(.horizontal, 12)
4853+
.frame(minHeight: 44)
4854+
.foregroundStyle(.white)
48094855
Rectangle().fill(.white.opacity(0.15)).frame(height: 1)
48104856
LayerButtons { mv in if let i = m.index(of: control.id) { mv.apply(&m.controls, i) } }
48114857
.padding(.horizontal, 10)
@@ -4819,6 +4865,8 @@ struct MappingPanel: View {
48194865
.background(GlassShape())
48204866
.clipShape(RoundedRectangle(cornerRadius: 18))
48214867
.overlay(RoundedRectangle(cornerRadius: 18).stroke(.white.opacity(0.18), lineWidth: 1))
4868+
.contentShape(Rectangle())
4869+
.onTapGesture { } // Panel gaps must not dismiss the selection beneath them.
48224870
.position(layout.center)
48234871
}
48244872

@@ -4838,7 +4886,7 @@ struct MappingPanel: View {
48384886
private var layout: Placement {
48394887
let cx = CGFloat(control.nx) * screen.width
48404888
let cy = CGFloat(control.ny) * screen.height
4841-
let r = TouchControlsModel.diameter(control) / 2
4889+
let r = (resizeDiameter ?? TouchControlsModel.diameter(control)) / 2
48424890
let gap: CGFloat = 14, edge: CGFloat = 8
48434891

48444892
for size in [CGSize(width: 340, height: 236),

‎app/Madeira/GestureZones.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ struct GestureZonePanel: View {
427427
.background(GlassShape())
428428
.clipShape(RoundedRectangle(cornerRadius: 18))
429429
.overlay(RoundedRectangle(cornerRadius: 18).stroke(.white.opacity(0.18), lineWidth: 1))
430+
.contentShape(Rectangle())
431+
.onTapGesture { } // Panel gaps must not dismiss the selection beneath them.
430432
.position(x: panelX, y: screen.height / 2)
431433
}
432434

‎tests/host/check-control-presets.py‎

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
preset state from TouchControlPresets.swift) on the host: the built-in
77
controller layout on phone and tablet screens, built-ins read-only, "Custom
88
Layout N" naming, encode/decode and what loading a layout puts on screen.
9+
Where SwiftUI is available, it also checks the production canvas hit shape.
910
1011
The preset is checked as data: its name, id and its 18 bindings, each an XInput
1112
mapping that produces a non-neutral pad sample; and the key sticks' glyphs.
@@ -199,26 +200,103 @@
199200
let old = try! JSONDecoder().decode(ControlPreset.self, from: Data(#"{"id":"a","name":"Old","controls":[]}"#.utf8))
200201
require(old.zones == nil && ControlPresetStore.zones(of: old).isEmpty, "layouts saved before zones still load")
201202
203+
__EDITOR_SIZE_TESTS__
204+
202205
if failures > 0 { print("\(failures) FAILED"); exit(1) }
203206
print("PASS: built-in controller layout, layout store, naming and loading")
204207
'''
205208

209+
# Compile the production editor callbacks against small state fixtures.
210+
def function(source, start):
211+
i = source.index(start); b = source.index('{', i); depth = 1; k = b + 1
212+
while depth:
213+
depth += (source[k] == '{') - (source[k] == '}'); k += 1
214+
return source[i:k]
215+
216+
217+
panel = content[content.index('struct MappingPanel: View {'):]
218+
setter = panel.split('set: { value in', 1)[1].split('}), in:', 1)[0]
219+
backdrop = content.split('Color.clear.contentShape(editorCanvas(geo.size))', 1)[1]
220+
dismiss = function(backdrop, '.onTapGesture {').split('{', 1)[1][:-1]
221+
sizes = '\n'.join(function(content, signature) for signature in ('static func diameter(', 'static func size('))
222+
canvas = function(content, 'private func editorCanvas(')
223+
editor_tests = r'''
224+
final class EditorControls {
225+
static let shared = EditorControls()
226+
static let baseDiameter: CGFloat = 64
227+
var sizeScale = 1.0
228+
var controls: [TouchControl] = []
229+
var selected: UUID? = UUID()
230+
var editing = true
231+
func index(of id: UUID?) -> Int? { controls.firstIndex { $0.id == id } }
232+
''' + sizes + r'''
233+
}
234+
final class EditorZones {
235+
static var enabled = true
236+
var selected: UUID? = UUID()
237+
var zones: [GestureZone] = []
238+
}
239+
func setSize(_ control: TouchControl, _ value: Double, _ m: EditorControls) {
240+
''' + setter + r'''
241+
}
242+
func dismissPanel(_ m: EditorControls, _ zones: EditorZones) {
243+
''' + dismiss + r'''
244+
}
245+
let editor = EditorControls()
246+
var first = TouchControl(), second = TouchControl()
247+
editor.controls = [first, second]
248+
setSize(first, 3.0, editor)
249+
require(editor.controls[0].scale == 3 && editor.controls[1].scale == 1, "size slider changes only its selected control")
250+
editor.controls[0].locked = true
251+
setSize(first, 0.5, editor)
252+
require(editor.controls[0].scale == 3, "size slider honours the current lock even with an older view value")
253+
setSize(TouchControl(), 0.5, editor)
254+
require(editor.controls.count == 2 && editor.controls[0].scale == 3, "a removed slider target is ignored")
255+
editor.controls[0].locked = nil
256+
setSize(first, 0.5, editor)
257+
let resized = try! JSONDecoder().decode([TouchControl].self, from: JSONEncoder().encode(editor.controls))
258+
require(resized[0].scale == 0.5 && resized[1].scale == 1, "slider sizes survive the controls file format")
259+
let editorZones = EditorZones(), beforeDismiss = editor.controls
260+
dismissPanel(editor, editorZones)
261+
require(editor.selected == nil && editorZones.selected == nil && editor.editing,
262+
"an outside tap dismisses either panel and keeps edit mode")
263+
require(editor.controls == beforeDismiss, "dismissing the panel preserves control edits")
264+
#if canImport(SwiftUI)
265+
typealias TouchControlsModel = EditorControls
266+
typealias GestureZonesModel = EditorZones
267+
let m = editor, zones = editorZones
268+
''' + canvas + r'''
269+
let canvasScreen = CGSize(width: 800, height: 400)
270+
var canvasControl = TouchControl()
271+
editor.controls = [canvasControl]
272+
var canvasZone = GestureZone(); canvasZone.x = 0.4; canvasZone.w = 0.4
273+
editorZones.zones = [canvasZone]
274+
let area = editorCanvas(canvasScreen)
275+
require(!area.contains(CGPoint(x: 400, y: 200)), "a control overlapping a zone is excluded from canvas taps")
276+
require(!area.contains(CGPoint(x: 600, y: 300)), "zone taps stay with the zone editor")
277+
require(area.contains(CGPoint(x: 720, y: 380)), "empty space takes dismissal taps")
278+
EditorZones.enabled = false
279+
require(editorCanvas(canvasScreen).contains(CGPoint(x: 600, y: 300)), "disabled zones do not block dismissal")
280+
EditorZones.enabled = true; editorZones.zones = []
281+
require(editorCanvas(canvasScreen).contains(CGPoint(x: 490, y: 200)), "canvas starts beyond a normal-size control")
282+
editor.controls[0].scale = 3
283+
require(!editorCanvas(canvasScreen).contains(CGPoint(x: 490, y: 200)), "the tap exclusion follows a resized control")
284+
editor.controls[0].action = .pad("RT"); editor.controls[0].scale = 1
285+
require(!editorCanvas(canvasScreen).contains(CGPoint(x: 445, y: 200)), "wide shoulder controls keep their full touch region")
286+
#endif
287+
'''
288+
tests = tests.replace('__EDITOR_SIZE_TESTS__', editor_tests)
289+
206290
with tempfile.TemporaryDirectory(prefix='madeira-presets-') as tmp:
207291
src, exe = Path(tmp) / 'main.swift', Path(tmp) / 'check'
208292
# CoreGraphics where it exists: on macOS, Foundation alone no longer gives CGRect
209293
# its members (midX, width, ...); Linux's Foundation still does.
210-
header = 'import Foundation\n#if canImport(CoreGraphics)\nimport CoreGraphics\n#endif\n'
294+
header = 'import Foundation\n#if canImport(CoreGraphics)\nimport CoreGraphics\n#endif\n#if canImport(SwiftUI)\nimport SwiftUI\n#endif\n'
211295
src.write_text(header + actions + pure_touch + pure + tests)
212296
subprocess.run([os.environ.get('SWIFTC', 'swiftc'), str(src), '-o', str(exe)], check=True)
213297
subprocess.run([str(exe)], check=True)
214298

215299
# ---------------------------------------------------------------- Part B
216-
def function(source, start):
217-
i = source.index(start); b = source.index('{', i); depth = 1; k = b + 1
218-
while depth:
219-
depth += (source[k] == '{') - (source[k] == '}'); k += 1
220-
return source[i:k]
221-
222300
gamepad = (app / 'GamepadInput.swift').read_text()
223301
for flag, where in (('MADEIRA_CONTROL_PRESETS', presets), ('MADEIRA_CONTROLS_EDITOR_DONE', content)):
224302
assert f'.flag("{flag}")' in where, flag + ' is a kill switch'
@@ -240,6 +318,8 @@ def function(source, start):
240318
assert 'm.controls.isEmpty' in default and 'm.needsDefaultLayout = false' in default, 'applied once, never over controls'
241319
assert 'Self.touchPad' in function(presets, 'var defaultPending: Bool {'), 'no controller default without touch XInput'
242320
overlay = function(content, 'struct TouchControlsOverlay: View {')
321+
assert overlay.index('Color.clear.contentShape(editorCanvas(geo.size))') < overlay.index('GestureZonesLayer('), 'dismissal stays behind editable controls'
322+
assert panel.index('.onTapGesture { }') < panel.index('.position(layout.center)'), 'only the panel consumes its background taps'
243323
assert 'ControlPresetsModel.enabled && m.visible && !m.editing' in overlay, 'menu only while touch controls are shown'
244324
assert 'if m.editing && Self.editorDone {' in overlay and 'Text("Done")' in overlay
245325
assert 'TouchControlsOverlay.showsLayoutMenu(self) ? 3 : 2' in model, 'the menu button is hit-testable'

0 commit comments

Comments
 (0)