Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions app/Madeira/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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()) }
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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 }
Expand Down Expand Up @@ -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))
Expand All @@ -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))
}
}

Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions app/Madeira/Library.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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() }))
Expand Down
8 changes: 8 additions & 0 deletions app/Madeira/SettingsPages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion docs/LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/host/check-frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(')
Expand Down
Loading