mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
537 lines
20 KiB
Swift
537 lines
20 KiB
Swift
//
|
|
// DataContextSwitcherMenu.swift
|
|
// Yattee
|
|
//
|
|
// The one place the selected data context is switched from.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - Rows
|
|
|
|
/// The switchable contexts, as rows.
|
|
///
|
|
/// Shared by every presentation of the switcher so a context that is offered in
|
|
/// one place is offered — and named — identically in all of them.
|
|
struct DataContextSwitcherRows: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
/// Called after a switch, for presentations that have to close themselves.
|
|
var onSwitch: (() -> Void)?
|
|
|
|
private var validator: DataContextValidator? { appEnvironment?.dataContextValidator }
|
|
|
|
var body: some View {
|
|
ForEach(validator?.listedContexts ?? [.local], id: \.self) { context in
|
|
row(for: context)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(for context: DataContext) -> some View {
|
|
// A menu row is a single label — a `VStack` inside one renders as
|
|
// nothing on macOS — so the status travels in the title rather than as
|
|
// a second line. A context that cannot be switched to says so there
|
|
// too: `title(for:in:)` appends the *display* status, which is what
|
|
// turns a login on a switched-off source into "… — Source switched
|
|
// off" rather than a row that looks ordinary and does nothing.
|
|
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
|
|
|
|
#if os(macOS)
|
|
// The mark has to be the menu item's own. A `Label`'s `systemImage` is
|
|
// dropped by a macOS menu row, leaving the selected context
|
|
// indistinguishable from the rest; a `Toggle` is what SwiftUI renders
|
|
// as a checked item, mark character included.
|
|
Toggle(isOn: Binding(
|
|
get: { isCurrent(context) },
|
|
set: { isOn in
|
|
guard isOn else { return }
|
|
switchTo(context)
|
|
}
|
|
)) {
|
|
Text(title)
|
|
}
|
|
.disabled(isRefused(context))
|
|
#else
|
|
Button {
|
|
switchTo(context)
|
|
} label: {
|
|
if isCurrent(context) {
|
|
Label(title, systemImage: "checkmark")
|
|
} else {
|
|
Text(title)
|
|
}
|
|
}
|
|
.disabled(isRefused(context))
|
|
#endif
|
|
}
|
|
|
|
private func switchTo(_ context: DataContext) {
|
|
// The row is disabled as well; this is what makes the refusal true on
|
|
// any presentation where a disabled control can still be activated.
|
|
guard !isRefused(context) else { return }
|
|
|
|
appEnvironment?.switchDataContext(to: context)
|
|
onSwitch?()
|
|
}
|
|
|
|
private func isCurrent(_ context: DataContext) -> Bool {
|
|
appEnvironment?.accountsManager.currentContext == context
|
|
}
|
|
|
|
/// Listed, but not something the user may pick.
|
|
///
|
|
/// The current context is never refused, whatever its source is doing: it
|
|
/// is already the selection, picking it changes nothing, and a disabled
|
|
/// macOS menu item is not the place to hang the app's only checkmark. It
|
|
/// carries its status in the title instead, which is the part that has to
|
|
/// survive.
|
|
private func isRefused(_ context: DataContext) -> Bool {
|
|
guard let validator, !isCurrent(context) else { return false }
|
|
return !validator.isSwitchable(context)
|
|
}
|
|
}
|
|
|
|
// MARK: - Labels
|
|
|
|
/// Names the switchable contexts.
|
|
enum DataContextSwitcherLabels {
|
|
/// The context's name, plus whatever the user needs to tell it apart.
|
|
@MainActor
|
|
static func title(for context: DataContext, in appEnvironment: AppEnvironment?) -> String {
|
|
guard let appEnvironment else { return String(localized: "subscriptions.account.local.device") }
|
|
|
|
return title(
|
|
for: context,
|
|
accountsManager: appEnvironment.accountsManager,
|
|
validator: appEnvironment.dataContextValidator,
|
|
username: { appEnvironment.accountCredentialsManager(for: $0)?.username(for: $0) }
|
|
)
|
|
}
|
|
|
|
/// The same label, built from only what it needs.
|
|
///
|
|
/// Taking the pieces rather than the environment is what lets the naming
|
|
/// rules be exercised on their own: which suffix a name gets depends on the
|
|
/// other accounts, the stored identifier and the host, and on nothing else.
|
|
@MainActor
|
|
static func title(
|
|
for context: DataContext,
|
|
accountsManager: AccountsManager,
|
|
validator: DataContextValidator,
|
|
username: (Account) -> String?
|
|
) -> String {
|
|
let name = validator.displayName(for: context)
|
|
|
|
guard case let .account(id) = context, let account = accountsManager.account(id: id) else {
|
|
return name
|
|
}
|
|
|
|
var title = name
|
|
|
|
let peers = peers(sharing: name, accountsManager: accountsManager, validator: validator)
|
|
if peers.count > 1 {
|
|
title = disambiguated(
|
|
name,
|
|
for: account,
|
|
among: peers,
|
|
accountsManager: accountsManager,
|
|
username: username
|
|
)
|
|
}
|
|
|
|
let status = accountsManager.displayStatus(for: account)
|
|
guard status != .signedIn else { return title }
|
|
|
|
return "\(title) — \(AccountStatusBadge.text(for: status))"
|
|
}
|
|
|
|
/// What a shared name needs added to it before it names one account.
|
|
///
|
|
/// The identifier comes first: two logins on the *same* instance share a
|
|
/// host, so a host suffix would repeat the server and still leave the user
|
|
/// choosing between two identical rows. The host — the source's title when
|
|
/// it has one — alone is what is left when the identifier is not on this
|
|
/// device — tvOS never receives one. Neither is repeated when the name
|
|
/// already carries it: the default name for an account *is*
|
|
/// `identifier@host`, so appending it unguarded produced two rows reading
|
|
/// `X · X`.
|
|
///
|
|
/// The source is the last resort and the only one that can name what two
|
|
/// logins of the same server user on two copies of the same server differ
|
|
/// by — the thing the user chose when adding the second source.
|
|
static func disambiguated(_ name: String, username: String?, host: String?, source: String? = nil) -> String {
|
|
var parts: [String] = []
|
|
|
|
if let username, !username.isEmpty, !name.localizedCaseInsensitiveContains(username) {
|
|
if let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) {
|
|
parts.append("\(username)@\(host)")
|
|
} else {
|
|
parts.append(username)
|
|
}
|
|
} else if let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) {
|
|
parts.append(host)
|
|
}
|
|
|
|
if let source, !source.isEmpty, name.localizedCaseInsensitiveCompare(source) != .orderedSame {
|
|
parts.append(source)
|
|
}
|
|
|
|
guard !parts.isEmpty else { return name }
|
|
return "\(name) · \(parts.joined(separator: " · "))"
|
|
}
|
|
|
|
/// The identifier and the host first, and the source only once they have
|
|
/// failed: two logins on one server are told apart by who they are, and
|
|
/// naming the server as well would add a word that is the same on both
|
|
/// rows. It is when that suffix still collides — the same server user
|
|
/// signed in on two sources pointing at the same host — that the source is
|
|
/// the only thing left that differs.
|
|
@MainActor
|
|
private static func disambiguated(
|
|
_ name: String,
|
|
for account: Account,
|
|
among peers: [Account],
|
|
accountsManager: AccountsManager,
|
|
username: (Account) -> String?
|
|
) -> String {
|
|
// The same short label the default account name is built from — the
|
|
// source's title, host only as the fallback — or a name that already
|
|
// reads `user@Title` would fail the "already carries it" guard and
|
|
// grow a second, longer `user@full.host` suffix.
|
|
func withoutSource(_ peer: Account) -> String {
|
|
disambiguated(
|
|
name,
|
|
username: username(peer),
|
|
host: accountsManager.instance(for: peer)?.shortLabel
|
|
)
|
|
}
|
|
|
|
let own = withoutSource(account)
|
|
let collides = peers.contains { $0.id != account.id && withoutSource($0) == own }
|
|
guard collides else { return own }
|
|
|
|
return disambiguated(
|
|
name,
|
|
username: username(account),
|
|
host: accountsManager.instance(for: account)?.shortLabel,
|
|
source: accountsManager.instance(for: account)?.displayName
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
private static func peers(
|
|
sharing name: String,
|
|
accountsManager: AccountsManager,
|
|
validator: DataContextValidator
|
|
) -> [Account] {
|
|
accountsManager.selectableAccounts.filter { validator.displayName(for: $0) == name }
|
|
}
|
|
}
|
|
|
|
// MARK: - Visibility
|
|
|
|
/// When the switcher is offered at all.
|
|
///
|
|
/// One rule for every presentation: no switcher until an account exists to
|
|
/// switch to. A user who has never signed in anywhere has only local data, and
|
|
/// a menu offering the one context they are already in is chrome with nothing
|
|
/// to say. Orphans and accounts still waiting for their instance don't count —
|
|
/// neither is a context the switcher would actually list.
|
|
enum DataContextSwitcherVisibility {
|
|
/// The settings presentations: the accounts screen and the subscriptions
|
|
/// settings row. Only the existence of an account gates these — they are
|
|
/// where "Show Account Pickers" is explained and turned back on, so the
|
|
/// toggle itself must not hide them.
|
|
@MainActor
|
|
static func showsSwitcher(in appEnvironment: AppEnvironment?) -> Bool {
|
|
guard let appEnvironment else { return false }
|
|
return !appEnvironment.accountsManager.selectableAccounts.isEmpty
|
|
}
|
|
|
|
/// Everything outside Settings: the toolbar menus, the tvOS buttons, the
|
|
/// macOS sidebar row. Same account rule, plus the user's toggle.
|
|
@MainActor
|
|
static func showsPickers(in appEnvironment: AppEnvironment?) -> Bool {
|
|
guard let appEnvironment else { return false }
|
|
return appEnvironment.settingsManager.showAccountPickers && showsSwitcher(in: appEnvironment)
|
|
}
|
|
|
|
#if os(iOS)
|
|
/// The iOS toolbar menus: same rule again, minus the regular widths, where
|
|
/// the sidebar layout already pins the switcher in its bottom bar — a
|
|
/// toolbar button there would say the same thing twice, the way macOS
|
|
/// toolbars already leave it to the sidebar row.
|
|
@MainActor
|
|
static func showsToolbarPickers(
|
|
in appEnvironment: AppEnvironment?,
|
|
horizontalSizeClass: UserInterfaceSizeClass?
|
|
) -> Bool {
|
|
horizontalSizeClass == .compact && showsPickers(in: appEnvironment)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Glyph
|
|
|
|
/// How the switcher's icon reads.
|
|
///
|
|
/// Shared by every presentation of the switcher — the toolbar menu, the
|
|
/// settings row, the tvOS button — so the same account health cannot look
|
|
/// healthy in one of them and not in another.
|
|
enum DataContextSwitcherGlyph {
|
|
/// Whether the icon should ask to be noticed.
|
|
///
|
|
/// Only the states the user can actually clear: signing in again, turning
|
|
/// the instance back on, or switching away. A context that is merely still
|
|
/// resolving is not one of them — badging `checking` would put an
|
|
/// exclamation mark on every cold launch, before the Keychain has answered.
|
|
static func needsAttention(_ validity: DataContextValidity) -> Bool {
|
|
switch validity {
|
|
case .valid:
|
|
return false
|
|
case .removed:
|
|
return true
|
|
case let .temporarilyUnavailable(reason):
|
|
switch reason {
|
|
case .instanceDisabled, .credentialRejected, .unreachable: return true
|
|
case .checking, .instancePending, .credentialMissing: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The filled glyph says an account is selected; the badge says that
|
|
/// account cannot be used right now, which is the whole reason the user
|
|
/// would reach for this control.
|
|
static func systemImage(for context: DataContext, validity: DataContextValidity) -> String {
|
|
guard !context.isLocal else { return "person.crop.circle" }
|
|
|
|
if needsAttention(validity) { return "person.crop.circle.badge.exclamationmark" }
|
|
return "person.crop.circle.fill"
|
|
}
|
|
|
|
/// Orange — the colour the settings entry already badges attention with —
|
|
/// and no tint at all otherwise, so a healthy switcher keeps whatever
|
|
/// styling the toolbar or the list gives it.
|
|
static func tint(for context: DataContext, validity: DataContextValidity) -> Color? {
|
|
guard !context.isLocal, needsAttention(validity) else { return nil }
|
|
return .orange
|
|
}
|
|
|
|
@MainActor
|
|
static func systemImage(in appEnvironment: AppEnvironment?) -> String {
|
|
guard let appEnvironment else { return "person.crop.circle" }
|
|
|
|
return systemImage(
|
|
for: appEnvironment.accountsManager.currentContext,
|
|
validity: appEnvironment.dataContextValidator.validity
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
static func tint(in appEnvironment: AppEnvironment?) -> Color? {
|
|
guard let appEnvironment else { return nil }
|
|
|
|
return tint(
|
|
for: appEnvironment.accountsManager.currentContext,
|
|
validity: appEnvironment.dataContextValidator.validity
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The switcher's icon, health included.
|
|
///
|
|
/// Hidden from assistive technology on purpose: every presentation names the
|
|
/// switcher in words beside it, and an unlabelled `Image` announces its symbol
|
|
/// — a control that read "Account" while healthy started reading
|
|
/// "person.crop.circle.badge.exclamationmark" the moment it badged.
|
|
struct DataContextSwitcherIcon: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
let image = Image(systemName: DataContextSwitcherGlyph.systemImage(in: appEnvironment))
|
|
|
|
Group {
|
|
if let tint = DataContextSwitcherGlyph.tint(in: appEnvironment) {
|
|
image.foregroundStyle(tint)
|
|
} else {
|
|
image
|
|
}
|
|
}
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Menu
|
|
|
|
#if !os(tvOS)
|
|
/// The toolbar switcher: one tap, no confirmation — switching is reversible.
|
|
struct DataContextSwitcherMenu: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
Label {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
}
|
|
.help(contextTitle)
|
|
.accessibilityLabel(Text(String(localized: "accounts.switcher.title")))
|
|
.accessibilityValue(Text(contextTitle))
|
|
}
|
|
|
|
private var contextTitle: String {
|
|
DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)
|
|
}
|
|
|
|
private var currentContext: DataContext {
|
|
appEnvironment?.accountsManager.currentContext ?? .local
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
/// A settings row naming the current context, with the switcher attached.
|
|
struct DataContextSwitcherSettingsRow: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
// The same menu the Subscriptions, History and Playlists headers use:
|
|
// one click, options in place, rather than a full-screen round trip.
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
rowLabel
|
|
}
|
|
#elseif os(macOS)
|
|
// A macOS menu label renders its first text and nothing else — the
|
|
// trailing name was dropped at every width, leaving the row silent
|
|
// about which account is selected. So the name is the menu's *own*
|
|
// label, and the row carries only what names the control.
|
|
// `LabeledContent` overflows rather than compress its label, so at the
|
|
// sidebar's 140 pt minimum a captioned row (caption + spacing + the
|
|
// name's floor ≈ 158 pt) juts past both edges. The caption is the one
|
|
// part the icon already says, so it is the part that goes.
|
|
ViewThatFits(in: .horizontal) {
|
|
macOSRow(captioned: true)
|
|
macOSRow(captioned: false)
|
|
}
|
|
#else
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
rowLabel
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
@ViewBuilder private func macOSRow(captioned: Bool) -> some View {
|
|
LabeledContent {
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
Text(contextTitle)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
// The floor must sit on the menu itself: a borderless menu button
|
|
// lays out its label alone, so a frame inside it never reaches the
|
|
// row's layout, and the caption would keep its ideal width while
|
|
// the name shrank to an ellipsis. Out here the menu claims the
|
|
// width and the caption is what gives way.
|
|
.frame(minWidth: Self.minimumNameWidth, alignment: .trailing)
|
|
.layoutPriority(1)
|
|
.help(contextTitle)
|
|
.accessibilityLabel(Text(String(localized: "accounts.switcher.title")))
|
|
.accessibilityValue(Text(contextTitle))
|
|
} label: {
|
|
Label {
|
|
if captioned {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// What the trailing name may never be squeezed below.
|
|
///
|
|
/// A row this narrow — the macOS sidebar's footer at a dragged-in column
|
|
/// width — hands the leading label its ideal width first and leaves the
|
|
/// name whatever is left, and a `Text` will shrink all the way to a lone
|
|
/// ellipsis before it complains. A floor turns that around: the name keeps
|
|
/// enough characters to be recognised, and the word naming the control —
|
|
/// which the icon beside it already says — is what truncates instead.
|
|
private static let minimumNameWidth: CGFloat = 80
|
|
|
|
#if !os(macOS)
|
|
private var rowLabel: some View {
|
|
HStack {
|
|
Label {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
Spacer(minLength: 8)
|
|
Text(contextTitle)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(minWidth: Self.minimumNameWidth, alignment: .trailing)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private var contextTitle: String {
|
|
DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)
|
|
}
|
|
|
|
private var currentContext: DataContext {
|
|
appEnvironment?.accountsManager.currentContext ?? .local
|
|
}
|
|
}
|
|
|
|
// MARK: - tvOS button
|
|
|
|
#if os(tvOS)
|
|
/// The switcher for placing beside other tvOS controls: a menu, like the
|
|
/// menu-style pickers in Settings, so switching is one click instead of a
|
|
/// full-screen round trip.
|
|
struct DataContextSwitcherButton: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
Label {
|
|
// One line always: beside other header buttons the name would
|
|
// wrap into a tall two-line lozenge before it truncates.
|
|
Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentContext: DataContext {
|
|
appEnvironment?.accountsManager.currentContext ?? .local
|
|
}
|
|
}
|
|
#endif
|