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
1 change: 1 addition & 0 deletions app/Madeira/ConfigCatalog.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ extension ConfigCatalog {
ConfigOption(key: "env.MADEIRA_DOWNLOAD_NOTIFICATIONS", title: "", kind: .bool, defaultValue: "1", category: "Steam & Dock", note: "Local notifications when a Steam download finishes, fails or pauses while Madeira is in the background (permission asked at the first download). 0: none, and no permission request.", choices: [], sources: ["app/Madeira/SteamDownloadBackground.swift"]),
ConfigOption(key: "env.MADEIRA_INSTALL_DEFAULT_KEY", title: "", kind: .bool, defaultValue: "1", category: "Steam & Dock", note: "The install scripts' programs of a game: its own folder (one level down) and the shared redistributables Steam keeps next to it, and how many scripts were read.", choices: [], sources: ["app/Madeira/DockInstallers.swift"]),
ConfigOption(key: "env.MADEIRA_LIBRARY_COLLAPSE", title: "", kind: .bool, defaultValue: "1", category: "Steam & Dock", note: "MADEIRA_LIBRARY_COLLAPSE=0: the section titles do not collapse.", choices: [], sources: ["app/Madeira/SteamGames.swift"]),
ConfigOption(key: "env.MADEIRA_LIBRARY_TABS", title: "", kind: .bool, defaultValue: "1", category: "Steam & Dock", note: "MADEIRA_LIBRARY_TABS=0: no store tabs, every section on one page.", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_ONBOARDING", title: "First-run Steam setup", kind: .bool, defaultValue: "1", category: "Steam & Dock", note: "`env.MADEIRA_ONBOARDING = 0` (madeira.cfg or the environment) never opens setup and hides \"Run setup again\". On by default.", choices: [], sources: ["app/Madeira/Onboarding.swift"]),
ConfigOption(key: "env.MADEIRA_STEAM_ALLOW_CRASHPAD", title: "Steam web helper: allow its crash reporter", kind: .bool, defaultValue: "0", category: "Steam & Dock", note: "ml2210: 1 (the Steam Big Picture list) lets steamwebhelper.exe start exactly one kind of child, --type=crashpad-handler, with its command line unchanged; without it the 32-bit web helper ends itself at start (crash server failed to launch). Every other --type= child stays refused. Off (default): all are refused.", choices: [], sources: ["build/ntdll-unix/process_ios.c"]),
ConfigOption(key: "env.MADEIRA_STEAM_CLOUD", title: "", kind: .bool, defaultValue: "1", category: "Steam & Dock", note: "Whether Steam Cloud saves sync: Settings › Steam Cloud saves, kept in madeira.cfg as env.MADEIRA_STEAM_CLOUD (on unless it is 0; docs/STEAM_CLOUD.md). Turning it back on syncs the installed games at once.", choices: [], sources: ["app/Madeira/SteamGames.swift", "app/Madeira/SteamOwnedLibrary.swift"]),
Expand Down
138 changes: 118 additions & 20 deletions app/Madeira/Library.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2950,6 +2950,91 @@ struct LibrarySectionHeader<Trailing: View>: View {
}
}

/// The library's store tabs: with Group by Platform, a segmented control at the top of the
/// page shows one store's section at a time (Steam, the games you added, GOG, itch.io, Epic
/// Games, EA app) or All of them. Only the sections the page would show get a tab, and with
/// fewer than two there are none. env.MADEIRA_LIBRARY_TABS = 0: no tabs, every section on one page.
enum LibraryStoreTab: String, CaseIterable, Identifiable {
case all, steam, other, gog, itch, epic, ea
var id: String { rawValue }
static let key = "madeiraLibraryStoreTab"
static var enabled: Bool { MadeiraConfig.flag("MADEIRA_LIBRARY_TABS") }

/// Short, so every segment fits across a phone.
var title: String {
switch self {
case .all: return L("All")
case .steam: return "Steam"
case .other: return L("Other")
case .gog: return "GOG"
case .itch: return "itch.io"
case .epic: return "Epic"
case .ea: return "EA"
}
}

/// All, then a tab for each section the page shows; none with fewer than two sections.
/// `steam` is whether the page has a Steam section.
@MainActor static func available(steam: Bool) -> [LibraryStoreTab] {
guard enabled else { return [] }
var tabs: [LibraryStoreTab] = []
if steam { tabs.append(.steam) }
tabs.append(.other)
if GOGLibrary.enabled { tabs.append(.gog) }
if ItchLibrary.enabled { tabs.append(.itch) }
if EpicLibrary.enabled { tabs.append(.epic) }
if EALibrary.enabled { tabs.append(.ea) }
return tabs.count < 2 ? [] : [.all] + tabs
}

/// The stored tab, or All when it is not offered (its store was turned off).
static func current(_ stored: String, in tabs: [LibraryStoreTab]) -> LibraryStoreTab {
guard let tab = LibraryStoreTab(rawValue: stored), tabs.contains(tab) else { return .all }
return tab
}

func shows(_ section: LibraryStoreTab) -> Bool { self == .all || self == section }
}

/// The store tabs' control: the system segmented control, or, when its segments do not fit
/// across, a row of capsules that scrolls sideways.
struct LibraryStoreTabBar: View {
let tabs: [LibraryStoreTab]
@Binding var selection: String

private var picked: Binding<LibraryStoreTab> {
Binding(get: { LibraryStoreTab.current(selection, in: tabs) },
set: { tab in withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .easeInOut(duration: 0.2)) { selection = tab.rawValue } })
}

var body: some View {
ViewThatFits(in: .horizontal) {
Picker("Store", selection: picked) {
ForEach(tabs) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.fixedSize()
.frame(maxWidth: .infinity)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(tabs) { tab in
let on = picked.wrappedValue == tab
Button { picked.wrappedValue = tab } label: {
Text(tab.title).font(.subheadline.weight(.semibold))
.padding(.horizontal, 14).padding(.vertical, 7)
.foregroundStyle(on ? Color.white : Color.primary)
.background(Capsule().fill(on ? Color.accentColor : Color(uiColor: .tertiarySystemFill)))
}
.buttonStyle(.plain)
.accessibilityAddTraits(on ? .isSelected : [])
}
}
}
}
.accessibilityLabel("Store")
}
}

/// A section's cards or rows in the library's layout ("cards", "compact",
/// "list" or "compactList"): every section of the library page uses it.
struct LibraryCells<Item: Identifiable, Cell: View>: View {
Expand Down Expand Up @@ -3255,6 +3340,12 @@ struct LibraryView: View {
@AppStorage("madeiraLibraryCollapsedGroups") private var collapsedGroups = ""
// Collapsed state of the Other games section (MADEIRA_LIBRARY_COLLAPSE=0: no collapsing).
@AppStorage("madeiraLibraryHideOthers") private var hideOthers = false
// The store tab shown with Group by Platform (LibraryStoreTab).
@AppStorage(LibraryStoreTab.key) private var storeTab = LibraryStoreTab.all.rawValue
private var storeTabs: [LibraryStoreTab] {
group == "platform" ? LibraryStoreTab.available(steam: MadeiraDock.enabled && (SteamGamesSection.shown || SteamGamesSection.hasInstalled)) : []
}
private var currentTab: LibraryStoreTab { LibraryStoreTab.current(storeTab, in: storeTabs) }
// The sections follow the Steam section's games and sign-in (SteamGames.swift).
@ObservedObject private var steamGames = SteamGamesModel.shared
@ObservedObject private var steamLibrary = SteamOwnedLibrary.shared
Expand All @@ -3272,7 +3363,7 @@ struct LibraryView: View {
/// and sorting go game by game, so their order among the Steam games is the
/// order without them.
private var focusOrder: [LibraryEntry] {
guard group != "platform" else { return entries }
guard group != "platform" else { return currentTab.shows(.other) ? entries : [] }
// A library file edited by hand (or written by a fork) may repeat an id: keep the first.
let positions = Dictionary(model.entries.enumerated().map { ($1.id, $0) }, uniquingKeysWith: { first, _ in first })
let byID = Dictionary(entries.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
Expand Down Expand Up @@ -3529,6 +3620,7 @@ struct LibraryView: View {
ScrollViewReader { reader in
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if !storeTabs.isEmpty { LibraryStoreTabBar(tabs: storeTabs, selection: $storeTab) }
desktopButton
// The library's sections, as in the fork: Steam (installed and downloading
// Steam games, then Not installed), then Other games, the games you added.
Expand All @@ -3546,53 +3638,59 @@ struct LibraryView: View {
libraryItem(entry, list: list, dense: dense)
}
} else {
// A store tab shows only its own section (LibraryStoreTab).
let tab = currentTab
let steamFirst = MadeiraDock.enabled && SteamGamesSection.hasInstalled
if steamFirst {
if steamFirst && tab.shows(.steam) {
SteamGamesSection(search: search, layout: layout, sort: sort, width: viewport.size.width,
part: .installed, open: { selected = $0 })
}
if SteamGamesSection.shown {
VStack(alignment: .leading, spacing: 14) {
// Games are added with the + in the navigation bar.
LibrarySectionHeader(title: L("Other games"), count: entries.count,
collapsed: SteamGamesSection.collapsible ? $hideOthers : nil) { EmptyView() }
if hideOthers && SteamGamesSection.collapsible {
EmptyView()
} else if entries.isEmpty {
Text(search.isEmpty
? "Copy a game's folder into Madeira › wine › drive_c with the Files app, then tap + and choose its .exe."
: L("No other games match your search."))
.foregroundStyle(.secondary)
} else {
cells(entries, width: viewport.size.width)
if tab.shows(.other) {
VStack(alignment: .leading, spacing: 14) {
// Games are added with the + in the navigation bar.
LibrarySectionHeader(title: L("Other games"), count: entries.count,
collapsed: SteamGamesSection.collapsible ? $hideOthers : nil) { EmptyView() }
if hideOthers && SteamGamesSection.collapsible {
EmptyView()
} else if entries.isEmpty {
Text(search.isEmpty
? "Copy a game's folder into Madeira › wine › drive_c with the Files app, then tap + and choose its .exe."
: L("No other games match your search."))
.foregroundStyle(.secondary)
} else {
cells(entries, width: viewport.size.width)
}
}
}
if MadeiraDock.enabled {
if MadeiraDock.enabled && tab.shows(.steam) {
SteamGamesSection(search: search, layout: layout, sort: sort, width: viewport.size.width,
part: steamFirst ? .notInstalled : .all, open: { selected = $0 })
}
} else if !tab.shows(.other) {
EmptyView()
} else if model.entries.filter({ $0.desktop != true && $0.listedInLibrary && $0.steamAppID == nil && $0.epicAppName == nil && $0.gogProductID == nil && $0.itchGameID == nil && $0.eaOfferID == nil }).isEmpty {
ContentUnavailableView("Make yourself at home", systemImage: "gamecontroller", description: Text("Copy a game's folder into Madeira › wine › drive_c with the Files app, then tap + and choose its .exe."))
} else {
cells(entries, width: viewport.size.width)
}
// The account's GOG games (GOGGames.swift): installed ones open their
// Game details page, the others their download sheet.
if GOGLibrary.enabled {
if GOGLibrary.enabled && tab.shows(.gog) {
GOGGamesSection(search: search, layout: layout, width: viewport.size.width, open: { selected = $0 })
}
// The account's itch.io games (ItchGames.swift), the same way.
if ItchLibrary.enabled {
if ItchLibrary.enabled && tab.shows(.itch) {
ItchGamesSection(search: search, layout: layout, width: viewport.size.width, open: { selected = $0 })
}
// The account's Epic Games games (EpicGames.swift): installed ones open their
// Game details page, the others their download sheet.
if EpicLibrary.enabled {
if EpicLibrary.enabled && tab.shows(.epic) {
EpicGamesSection(search: search, layout: layout, width: viewport.size.width, open: { selected = $0 })
}
// The account's EA app games (EAGames.swift): installed ones open their
// Game details page, the others their download sheet.
if EALibrary.enabled {
if EALibrary.enabled && tab.shows(.ea) {
EAGamesSection(search: search, layout: layout, width: viewport.size.width, open: { selected = $0 })
}
}
Expand Down
Loading
Loading