diff --git a/app/Madeira/ContentView.swift b/app/Madeira/ContentView.swift index 43cc3cf4df..0c8531537d 100644 --- a/app/Madeira/ContentView.swift +++ b/app/Madeira/ContentView.swift @@ -4369,18 +4369,23 @@ struct TouchControlsOverlay: View { /// neighbours sit further apart than that, so nothing merges until it is /// moved almost touching. Before 26 the same views stack as plain material. @ViewBuilder private func controls(_ screen: CGSize, session: Bool) -> some View { + // A library session's Control opacity; full while editing. The opacity + // goes on the whole group, not on each button: inside a + // GlassEffectContainer the glass is rendered by the container as one + // layer over its children, so a button's own .opacity faded its label and + // left the glass as it was (the Opacity slider seemed to do nothing). + let opacity = session && !m.editing ? library.opacity : 1 let buttons = ForEach(m.controls) { c in TouchControlButton(control: c, screen: screen) - // A library session's Control opacity; full while editing. - .opacity(session && !m.editing ? library.opacity : 1) } if #available(iOS 26.0, *) { GlassEffectContainer(spacing: 12) { ZStack { buttons } .frame(width: screen.width, height: screen.height, alignment: .topLeading) } + .opacity(opacity) } else { - buttons + buttons.opacity(opacity) } } @@ -4520,6 +4525,9 @@ struct GlassShape: View { var capsule = false var cornerRadius: CGFloat = 18 var tint: Color? = nil + /// The touch controls' Solid button style (TouchControlStyle): a plain opaque + /// disc in place of the glass, the tint as its colour. + var solid = false fileprivate var shape: AnyShape { if circle { return AnyShape(Circle()) } if capsule { return AnyShape(Capsule()) } @@ -4537,7 +4545,9 @@ extension View { /// sibling of its glass ends up blurred underneath it; a label that is the /// glass view's own content is drawn on top, as the system's buttons are. @ViewBuilder func glassFace(_ g: GlassShape) -> some View { - if #available(iOS 26.0, *) { + if g.solid { + self.background(g.shape.fill(g.tint ?? TouchControlStyle.solidFill)) + } else if #available(iOS 26.0, *) { if let tint = g.tint { self.glassEffect(.regular.tint(tint.opacity(0.55)), in: g.shape) } else { @@ -4552,6 +4562,17 @@ extension View { } } +/// The touch controls' button style, remembered for every game: "glass" (the +/// system's Liquid Glass, as before) or "solid" (an opaque disc, no glass; +/// the Opacity slider still fades the whole set). Chosen in the in-game menu +/// and in Settings › Appearance. The overlay's top bar stays glass. +enum TouchControlStyle { + static let key = "madeiraControlsStyle" + static let styles: [(id: String, name: String)] = [("glass", "Glass"), ("solid", "Solid")] + static let solidFill = Color(red: 0.17, green: 0.17, blue: 0.19) + static func isSolid(_ style: String) -> Bool { style == "solid" } +} + extension ControlAction { /// Xbox face colours. Everything else is uncoloured: a wash of tint on every /// button would make the four that mean something unreadable. @@ -4570,11 +4591,13 @@ struct TouchControlButton: View { let control: TouchControl let screen: CGSize @ObservedObject private var m = TouchControlsModel.shared + @AppStorage(TouchControlStyle.key) private var style = "glass" @State private var isDown = false @State private var dragBase: CGPoint? @State private var stickDir: Int = -1 @State private var padVector = CGSize.zero + private var solid: Bool { TouchControlStyle.isSolid(style) } private var diameter: CGFloat { TouchControlsModel.diameter(control) } private var size: CGSize { TouchControlsModel.size(control) } private var isStick: Bool { control.action.stickKeys != nil || control.action.isPadStick } @@ -4608,19 +4631,19 @@ struct TouchControlButton: View { } } .frame(width: size.width, height: size.height) - .glassFace(GlassShape(circle: true, tint: a.padTint)) + .glassFace(GlassShape(circle: true, tint: a.padTint, solid: solid)) case .small: Text(a.padFaceLabel) .font(.system(size: size.height * 0.36, weight: .semibold)) .foregroundStyle(.white.opacity(isDown ? 1.0 : 0.85)) .frame(width: size.width, height: size.height) - .glassFace(GlassShape(circle: true)) + .glassFace(GlassShape(circle: true, solid: solid)) case .wide: Text(a.padFaceLabel) .font(.system(size: size.height * 0.42, weight: .semibold)) .foregroundStyle(.white.opacity(isDown ? 1.0 : 0.88)) .frame(width: size.width, height: size.height) - .glassFace(GlassShape(cornerRadius: size.height * 0.30)) + .glassFace(GlassShape(cornerRadius: size.height * 0.30, solid: solid)) case .capsule: Text(a.padFaceLabel) .font(.system(size: size.height * 0.40, weight: .semibold)) @@ -4630,7 +4653,7 @@ struct TouchControlButton: View { .padding(.horizontal, 6) .foregroundStyle(.white.opacity(isDown ? 1.0 : 0.85)) .frame(width: size.width, height: size.height) - .glassFace(GlassShape(capsule: true)) + .glassFace(GlassShape(capsule: true, solid: solid)) } } @@ -4644,7 +4667,7 @@ struct TouchControlButton: View { Text(control.action.label).font(.caption).foregroundStyle(.white.opacity(0.8)) } .frame(width: diameter, height: diameter) - .glassFace(GlassShape(circle: true)) + .glassFace(GlassShape(circle: true, solid: solid)) } else if control.action.stickKeys != nil { // Reuse the portrait pad's face so both look and animate the // same; scale it to whatever size this control was pinched to. @@ -4656,7 +4679,7 @@ struct TouchControlButton: View { height: JoystickFace.padRadius * 2) .scaleEffect(diameter / (JoystickFace.padRadius * 2)) .frame(width: diameter, height: diameter) - .glassFace(GlassShape(circle: true)) + .glassFace(GlassShape(circle: true, solid: solid)) } else if control.action.isPad { padFace } else { @@ -4667,7 +4690,7 @@ struct TouchControlButton: View { weight: .medium)) .foregroundStyle(.white.opacity(isDown ? 1.0 : 0.85)) .frame(width: size.width, height: size.height) - .glassFace(GlassShape(circle: true)) + .glassFace(GlassShape(circle: true, solid: solid)) } } .frame(width: size.width, height: size.height) diff --git a/app/Madeira/Library.swift b/app/Madeira/Library.swift index f54dc4abf0..0ba8055c5b 100644 --- a/app/Madeira/Library.swift +++ b/app/Madeira/Library.swift @@ -5482,6 +5482,7 @@ struct LibraryHUD: View { } @ObservedObject private var model = LibraryModel.shared @ObservedObject private var controls = TouchControlsModel.shared + @AppStorage(TouchControlStyle.key) private var controlStyle = "glass" /// A Madeira Dock start: its status, failure and Show desktop (DockStartScreen). @ObservedObject private var dockStart = DockStartScreen.shared private let sessionTools = MadeiraConfig.flag("MADEIRA_SESSION_TOOLS") @@ -5857,6 +5858,12 @@ struct LibraryHUD: View { } LabeledContent("Opacity") { Slider(value: $model.opacity, in: 0.15...1) } LabeledContent("Size") { Slider(value: $controls.sizeScale, in: 0.5...2) } + // Glass or an opaque disc (TouchControlStyle); the same choice as Settings › Appearance. + LabeledContent("Button style") { + Picker("Button style", selection: $controlStyle) { + ForEach(TouchControlStyle.styles, id: \.id) { Text($0.name).tag($0.id) } + }.pickerStyle(.segmented).frame(maxWidth: 220) + } Button("Edit controls", systemImage: "slider.horizontal.3") { controls.visible = true; controls.editing = true; model.menu = false } if GamepadInput.keyboardMouseAvailable { ControllerModeChoice(mode: Binding(get: { model.controllerMode }, set: { model.controllerMode = $0; model.saveCurrentProfile() })) diff --git a/app/Madeira/SettingsPages.swift b/app/Madeira/SettingsPages.swift index 59002dc05b..646eb05595 100644 --- a/app/Madeira/SettingsPages.swift +++ b/app/Madeira/SettingsPages.swift @@ -137,6 +137,7 @@ struct SettingsPageView: View { @ObservedObject private var liquidMetal = LiquidMetalSetting.shared @AppStorage(LibraryGridArm.key) private var zoomableGrid = false @AppStorage(GridZoomHapticPlayer.key) private var zoomHaptics = true + @AppStorage(TouchControlStyle.key) private var controlStyle = "glass" @State private var developerUI = !FrontendChoice.preferNew @State private var restartNotice = false @@ -203,6 +204,13 @@ struct SettingsPageView: View { } footer: { Text("Flowing chrome on the bars and the Desktop button. Off, they use the system's Liquid Glass.") } + Section { + Picker("Touch control buttons", selection: $controlStyle) { + ForEach(TouchControlStyle.styles, id: \.id) { Text($0.name).tag($0.id) } + } + } footer: { + Text("Glass is the system's Liquid Glass. Solid draws each button as a plain opaque disc; a game's Opacity slider still fades them.") + } Section { Toggle("New library grid", isOn: $zoomableGrid) if zoomableGrid { diff --git a/docs/LIBRARY.md b/docs/LIBRARY.md index f22ffc5c24..aec0fd971c 100644 --- a/docs/LIBRARY.md +++ b/docs/LIBRARY.md @@ -314,7 +314,9 @@ in-game menu: 1. touch controls on/off, their **Controller layout** (the built-in Xbox controller, the user's custom layouts, **Create new layout**; remembered per - game), their **Opacity** and **Size**, **Edit controls** + game), their **Opacity** and **Size**, their **Button style** (Glass, the + system's Liquid Glass, or Solid, a plain opaque disc; remembered for every + game, also in Settings › Appearance), **Edit controls** (the existing editor) and the **Keyboard** (its own key window, with an Esc/Ctrl/Shift/Alt/Tab/Enter/arrow row; modifiers latch); 2. the FPS limit, **Aspect & scaling**, **Eco mode** (the developer overlay's diff --git a/tests/host/check-frontend.py b/tests/host/check-frontend.py index 900865c91f..7b263687b1 100644 --- a/tests/host/check-frontend.py +++ b/tests/host/check-frontend.py @@ -651,6 +651,19 @@ def block(text, header): check('if touchPointerMode { touchModeBegan(touches); return }' in content, 'the game view handles the Touch pointer mode') check('TouchControlsModel.diameter(control)' in content and 'library.opacity' in content, "touch controls follow the session's size and opacity") +# The Opacity slider: on iOS 26 a button's own .opacity inside the GlassEffectContainer +# left the glass as it was, so the opacity goes on the container (and the pre-26 group). +overlay = block(content, 'private func controls(_ screen: CGSize, session: Bool)') +check('let opacity = session && !m.editing ? library.opacity : 1' in overlay and '.opacity(opacity)' in overlay + and 'TouchControlButton(control: c, screen: screen)\n }' in overlay, + 'the Opacity slider fades the whole GlassEffectContainer, not each button inside it') +# Button style: Glass or Solid (TouchControlStyle), in the in-game menu and Settings › Appearance. +button = block(content, 'struct TouchControlButton: View') +check('@AppStorage(TouchControlStyle.key) private var style = "glass"' in button + and button.count('solid: solid') == 7 and 'if g.solid {' in content + and 'Picker("Button style", selection: $controlStyle)' in lib + and 'Picker("Touch control buttons", selection: $controlStyle)' in (root / 'app/Madeira/SettingsPages.swift').read_text(), + 'Button style: every control face takes Solid; chosen in the in-game menu and Settings › Appearance') # Owner requests: the starting screen's glyph row, Settings credits last, no drive_c note. launch = block(hud, 'private func launchColumnView(')