mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
Master-of-time controls: signed speed slider + chapter scrubber
The viewer is now in full control of time, like a film editor at a jog
wheel. Two new controls in the bottom Glass bar:
- Speed slider, range −16…0…+16. Pull it left for reverse playback;
sit at 0 to freeze. Speed change captures localTime at the OLD
speed before applying the new one, so it doesn't retroactively
rescale time already elapsed.
- Chapter scrubber across the top of the bar. Drag to any point in
the current chapter's continuous timeline (Ch01 ≈ 326 seconds at
1×). Snaps to the resolved scene + offset under the playhead;
isPlaying / speed are preserved across a scrub.
SceneEngine internals:
- `localTime` is now clamped to [0, sceneDuration] so reverse play
halts cleanly at scene boundaries (the auto-advance task picks up
and crosses the boundary into the previous scene).
- `startAutoAdvance` schedules the right side of the boundary based
on the sign of speed. Crossing forward enters the next scene at
t=0; crossing backward enters the previous scene at t=duration.
- `setChapterPosition(_:)` resolves a chapter-global time into a
(scene, localTime) pair and re-anchors. Used by the scrubber.
- `currentChapterDuration` exposes the length the slider needs.
GlassControls rewrite:
- Two-row layout: scrubber on top, transport + speed + dots + nav
+ settings on bottom.
- The +/- discrete buttons are gone — speed is continuous.
- Speed label color-codes direction (green forward / orange reverse
/ grey frozen) and shows ❚❚ 0× when stopped.
- Wraps in a TimelineView so scrubber and label refresh at frame
rate while paused or playing.
Bundled, harness still 55/55 invariants, 0 audit errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7291645524
commit
715039bce2
2 changed files with 284 additions and 108 deletions
|
|
@ -3,10 +3,16 @@ import SwiftUI
|
|||
/// Observable engine driving the immersive linear scene flow.
|
||||
/// Single source of truth for scene-local time. Chapters consume `localTime(at:)`.
|
||||
///
|
||||
/// Pause is FIRST-CLASS: when `isPlaying` is false, `localTime(at:)` returns
|
||||
/// the value it had when the user paused, frozen. Pressing play resumes from
|
||||
/// where time stopped. The app launches paused so the very first scene
|
||||
/// doesn't auto-scrub past its opening beats before the user gets oriented.
|
||||
/// Time is FIRST-CLASS:
|
||||
///
|
||||
/// - `isPlaying` toggles wall-clock advance.
|
||||
/// - `speed` is SIGNED. Positive = forward, negative = reverse,
|
||||
/// `0` = paused (regardless of `isPlaying`). Range −16…+16.
|
||||
/// - `chapterPosition(at:)` and `setChapterPosition(_:)` give the user
|
||||
/// a "master of time" handle: scrub freely to any point in the
|
||||
/// current chapter's continuous timeline, in either direction.
|
||||
/// - The app launches paused so the very first scene doesn't auto-scrub
|
||||
/// past its opening beats before the viewer gets oriented.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SceneEngine {
|
||||
|
|
@ -23,9 +29,15 @@ final class SceneEngine {
|
|||
/// survives system clock drift over the lifetime of one session.
|
||||
private var playSessionStart: Double? = nil
|
||||
|
||||
/// Signed playback speed. Negative values play in reverse; 0 freezes
|
||||
/// time even if `isPlaying` is true. Clamped to [−16, +16].
|
||||
var speed: Double = 1.0
|
||||
let totalScenes: Int
|
||||
|
||||
/// Speed range exposed to the UI (signed, log-friendly).
|
||||
static let speedMin: Double = -16
|
||||
static let speedMax: Double = 16
|
||||
|
||||
/// Monotonic counter; incremented on every stopAutoAdvance() to invalidate in-flight asyncAfter blocks.
|
||||
private var advanceGeneration: Int = 0
|
||||
let sceneDuration: Double = 8.0
|
||||
|
|
@ -34,15 +46,15 @@ final class SceneEngine {
|
|||
/// windows of one continuous serial timeline (`Ch01Timeline`), so each
|
||||
/// scene's duration is the duration of its window. The total Ch01
|
||||
/// runtime at 1× ≈ 326 seconds — this is intentional pedagogical
|
||||
/// slo-mo; speed it up with `adjustSpeed`.
|
||||
/// slo-mo; speed it up with the speed slider.
|
||||
private static let durationOverrides: [SceneAddress: Double] = [
|
||||
SceneAddress(chapter: 1, scene: 0): 69.0, // Aaron writes α + sends to Ben
|
||||
SceneAddress(chapter: 1, scene: 1): 38.0, // α to Carl
|
||||
SceneAddress(chapter: 1, scene: 2): 67.5, // Ben writes β + sends to Aaron
|
||||
SceneAddress(chapter: 1, scene: 3): 33.0, // Carl writes γ — asymmetry
|
||||
SceneAddress(chapter: 1, scene: 4): 37.0, // γ to Aaron
|
||||
SceneAddress(chapter: 1, scene: 5): 37.5, // β to Carl
|
||||
SceneAddress(chapter: 1, scene: 6): 44.5, // γ to Ben + convergence
|
||||
SceneAddress(chapter: 1, scene: 0): 69.0,
|
||||
SceneAddress(chapter: 1, scene: 1): 38.0,
|
||||
SceneAddress(chapter: 1, scene: 2): 67.5,
|
||||
SceneAddress(chapter: 1, scene: 3): 33.0,
|
||||
SceneAddress(chapter: 1, scene: 4): 37.0,
|
||||
SceneAddress(chapter: 1, scene: 5): 37.5,
|
||||
SceneAddress(chapter: 1, scene: 6): 44.5,
|
||||
]
|
||||
|
||||
/// Effective duration for the current scene, honoring overrides.
|
||||
|
|
@ -59,27 +71,67 @@ final class SceneEngine {
|
|||
}
|
||||
|
||||
/// Compute scene-local time at a given wall-clock date, honoring pause
|
||||
/// state. Speed scales the LIVE delta only — already-elapsed time is
|
||||
/// preserved at whatever speed it was clocked at.
|
||||
/// state and signed speed. localTime is clamped to [0, sceneDuration].
|
||||
func localTime(at date: Date) -> Double {
|
||||
guard let start = playSessionStart else {
|
||||
return accumulatedLocal
|
||||
}
|
||||
let live = (date.timeIntervalSinceReferenceDate - start) * speed
|
||||
return max(0, accumulatedLocal + live)
|
||||
let dur = sceneDurationFor(address)
|
||||
return max(0, min(dur, accumulatedLocal + live))
|
||||
}
|
||||
|
||||
/// Progress within current scene (0..1), capped at 1. Honors per-scene
|
||||
/// duration overrides so a long scene's progress bar doesn't max out
|
||||
/// after only 8 seconds.
|
||||
/// Progress within current scene (0..1), capped at 1.
|
||||
func progress(at date: Date) -> Double {
|
||||
min(1.0, localTime(at: date) / sceneDurationFor(address))
|
||||
}
|
||||
|
||||
init() {
|
||||
self.totalScenes = AllChapters.totalScenes
|
||||
// Launch paused at t=0. The user sees a still title frame and
|
||||
// explicitly presses play (or the right arrow) to begin.
|
||||
// Launch paused at t=0.
|
||||
}
|
||||
|
||||
// MARK: - Chapter-level position (the slider's territory)
|
||||
|
||||
/// Total duration of the current chapter's timeline at 1× speed —
|
||||
/// the sum of all its scenes' durations.
|
||||
var currentChapterDuration: Double {
|
||||
var t: Double = 0
|
||||
for s in 0..<currentChapter.sceneCount {
|
||||
t += sceneDurationFor(SceneAddress(chapter: address.chapter, scene: s))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/// Position within the current chapter's timeline (0..currentChapterDuration).
|
||||
func chapterPosition(at date: Date) -> Double {
|
||||
var t: Double = 0
|
||||
for s in 0..<address.scene {
|
||||
t += sceneDurationFor(SceneAddress(chapter: address.chapter, scene: s))
|
||||
}
|
||||
return t + localTime(at: date)
|
||||
}
|
||||
|
||||
/// Seek to an arbitrary point in the current chapter's timeline.
|
||||
/// Resolves which scene that point falls in and updates accumulatedLocal.
|
||||
/// Preserves `isPlaying` and `speed`.
|
||||
func setChapterPosition(_ position: Double) {
|
||||
let clamped = max(0, min(currentChapterDuration, position))
|
||||
var remaining = clamped
|
||||
let chapter = address.chapter
|
||||
for s in 0..<currentChapter.sceneCount {
|
||||
let dur = sceneDurationFor(SceneAddress(chapter: chapter, scene: s))
|
||||
if remaining <= dur || s == currentChapter.sceneCount - 1 {
|
||||
let target = SceneAddress(chapter: chapter, scene: s)
|
||||
currentGlobal = target.globalIndex
|
||||
accumulatedLocal = remaining
|
||||
playSessionStart = isPlaying ? Date().timeIntervalSinceReferenceDate : nil
|
||||
stopAutoAdvance()
|
||||
if isPlaying { startAutoAdvance() }
|
||||
return
|
||||
}
|
||||
remaining -= dur
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
|
@ -114,43 +166,46 @@ final class SceneEngine {
|
|||
|
||||
func togglePlay() {
|
||||
if isPlaying {
|
||||
// Pausing: capture the live delta into the accumulator so the
|
||||
// next localTime() call returns exactly the frozen value.
|
||||
if let start = playSessionStart {
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
accumulatedLocal += (now - start) * speed
|
||||
let dur = sceneDurationFor(address)
|
||||
accumulatedLocal = max(0, min(dur, accumulatedLocal))
|
||||
}
|
||||
playSessionStart = nil
|
||||
isPlaying = false
|
||||
stopAutoAdvance()
|
||||
} else {
|
||||
// Resuming: anchor a new live delta from the current accumulator.
|
||||
playSessionStart = Date().timeIntervalSinceReferenceDate
|
||||
isPlaying = true
|
||||
startAutoAdvance()
|
||||
}
|
||||
}
|
||||
|
||||
func adjustSpeed(delta: Double) {
|
||||
// Capture current localTime at the OLD speed before changing speed,
|
||||
// so the speed change doesn't retroactively scale already-elapsed time.
|
||||
/// Set absolute speed. Captures whatever localTime the engine had at
|
||||
/// the OLD speed so a speed change doesn't retroactively rescale the
|
||||
/// time already elapsed.
|
||||
func setSpeed(_ s: Double) {
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
if let start = playSessionStart {
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
accumulatedLocal += (now - start) * speed
|
||||
let dur = sceneDurationFor(address)
|
||||
accumulatedLocal = max(0, min(dur, accumulatedLocal))
|
||||
playSessionStart = now
|
||||
}
|
||||
speed = max(0.25, min(4.0, speed + delta))
|
||||
speed = max(Self.speedMin, min(Self.speedMax, s))
|
||||
if isPlaying {
|
||||
stopAutoAdvance()
|
||||
startAutoAdvance()
|
||||
}
|
||||
}
|
||||
|
||||
func adjustSpeed(delta: Double) {
|
||||
setSpeed(speed + delta)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Clear scene-local time. If the engine is currently in the playing
|
||||
/// state, anchor a fresh play session from now; otherwise leave it nil
|
||||
/// so the next localTime() call returns 0 cleanly.
|
||||
private func resetSceneTime(playing: Bool) {
|
||||
accumulatedLocal = 0
|
||||
playSessionStart = playing ? Date().timeIntervalSinceReferenceDate : nil
|
||||
|
|
@ -159,32 +214,62 @@ final class SceneEngine {
|
|||
private func currentLocalTimeNow() -> Double {
|
||||
guard let start = playSessionStart else { return accumulatedLocal }
|
||||
let live = (Date().timeIntervalSinceReferenceDate - start) * speed
|
||||
return max(0, accumulatedLocal + live)
|
||||
let dur = sceneDurationFor(address)
|
||||
return max(0, min(dur, accumulatedLocal + live))
|
||||
}
|
||||
|
||||
/// Schedule a one-shot task that fires when localTime hits the next
|
||||
/// scene boundary in the current direction of travel:
|
||||
///
|
||||
/// - speed > 0: fires when localTime reaches sceneDuration (advance)
|
||||
/// - speed < 0: fires when localTime reaches 0 (retreat)
|
||||
/// - speed == 0: never fires (frozen)
|
||||
///
|
||||
/// Crossing a boundary jumps to the neighboring scene and re-anchors
|
||||
/// localTime at the appropriate end.
|
||||
private func startAutoAdvance() {
|
||||
advanceGeneration += 1
|
||||
let myGen = advanceGeneration
|
||||
// Auto-advance fires after the REMAINING time in this scene (so a
|
||||
// pause→resume midway through Ch1.3 doesn't reset the 24s clock).
|
||||
let remaining = sceneDurationFor(address) - currentLocalTimeNow()
|
||||
let interval = max(0.05, remaining / max(0.1, speed))
|
||||
let dur = sceneDurationFor(address)
|
||||
let now = currentLocalTimeNow()
|
||||
|
||||
let interval: Double
|
||||
if speed > 0.001 {
|
||||
interval = max(0.05, (dur - now) / speed)
|
||||
} else if speed < -0.001 {
|
||||
interval = max(0.05, now / -speed)
|
||||
} else {
|
||||
return // 0 speed → frozen, no advance
|
||||
}
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(interval))
|
||||
guard let self, self.advanceGeneration == myGen, self.isPlaying else { return }
|
||||
if self.currentGlobal < self.totalScenes - 1 {
|
||||
self.currentGlobal += 1
|
||||
self.resetSceneTime(playing: true)
|
||||
self.startAutoAdvance()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.playSessionStart = nil
|
||||
if self.speed > 0.001 {
|
||||
if self.currentGlobal < self.totalScenes - 1 {
|
||||
self.currentGlobal += 1
|
||||
self.resetSceneTime(playing: true)
|
||||
self.startAutoAdvance()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.playSessionStart = nil
|
||||
}
|
||||
} else if self.speed < -0.001 {
|
||||
if self.currentGlobal > 0 {
|
||||
self.currentGlobal -= 1
|
||||
let prevDur = self.sceneDurationFor(self.address)
|
||||
self.accumulatedLocal = prevDur
|
||||
self.playSessionStart = Date().timeIntervalSinceReferenceDate
|
||||
self.startAutoAdvance()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.playSessionStart = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAutoAdvance() {
|
||||
// Invalidate any pending asyncAfter blocks.
|
||||
advanceGeneration += 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,162 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Liquid Glass bottom control bar — play/pause, navigation, speed, progress dots.
|
||||
/// Liquid Glass bottom control bar — a film-editor-style transport with
|
||||
/// signed speed (reverse playback) and a chapter-position scrubber.
|
||||
///
|
||||
/// The viewer is the master of time:
|
||||
/// - Speed slider: −16× … 0 (frozen) … +16×. Pull it left for reverse.
|
||||
/// - Position scrubber: drag freely along the chapter's continuous
|
||||
/// timeline, in either direction. Snaps to scene starts on click.
|
||||
struct GlassControls: View {
|
||||
@Bindable var engine: SceneEngine
|
||||
@Environment(AppSettings.self) private var settings
|
||||
@State private var showSettings = false
|
||||
/// Local mirror of the live chapter position so SwiftUI's Slider has
|
||||
/// a binding it can drive without round-tripping through the engine
|
||||
/// during a drag. Updated from the engine via `.task`/`.onReceive`
|
||||
/// equivalents — here we just refresh on appear/each frame.
|
||||
@State private var scrubPosition: Double = 0
|
||||
@State private var isScrubbing: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GlassEffectContainer {
|
||||
HStack(spacing: 16) {
|
||||
// Navigation cluster
|
||||
HStack(spacing: 4) {
|
||||
navButton(icon: "backward.end.fill") { engine.goTo(global: 0) }
|
||||
navButton(icon: "chevron.left") { engine.previous() }
|
||||
playButton
|
||||
navButton(icon: "chevron.right") { engine.next() }
|
||||
navButton(icon: "forward.end.fill") { engine.goTo(global: engine.totalScenes - 1) }
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30)) { timeline in
|
||||
let livePosition = engine.chapterPosition(at: timeline.date)
|
||||
GlassEffectContainer {
|
||||
VStack(spacing: 6) {
|
||||
// Top row: position scrubber across the chapter.
|
||||
chapterScrubber(livePosition: livePosition)
|
||||
|
||||
// Bottom row: play/pause + speed slider + chapter dots + settings.
|
||||
HStack(spacing: 16) {
|
||||
playButton
|
||||
|
||||
speedControl
|
||||
|
||||
Spacer()
|
||||
|
||||
progressDots
|
||||
|
||||
Spacer()
|
||||
|
||||
navCluster
|
||||
|
||||
settingsButton
|
||||
}
|
||||
}
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Progress dots
|
||||
progressDots
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Speed + counter + settings
|
||||
HStack(spacing: 8) {
|
||||
navButton(icon: "minus") { engine.adjustSpeed(delta: -0.25) }
|
||||
Text(String(format: "%.1fx", engine.speed))
|
||||
.scaledFont(size: 11, weight: .bold, design: .monospaced)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36)
|
||||
navButton(icon: "plus") { engine.adjustSpeed(delta: 0.25) }
|
||||
|
||||
Text("\(engine.currentGlobal + 1)/\(engine.totalScenes)")
|
||||
.scaledFont(size: 11, weight: .medium, design: .monospaced)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
settingsButton
|
||||
}
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onAppear { scrubPosition = livePosition }
|
||||
.onChange(of: livePosition) { _, new in
|
||||
if !isScrubbing { scrubPosition = new }
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gear button → text-scale slider popover. Lives on the right side of
|
||||
/// the control bar so it doesn't fight the play/transport cluster on the
|
||||
/// left.
|
||||
// MARK: - Chapter scrubber (top row)
|
||||
|
||||
private func chapterScrubber(livePosition: Double) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(String(format: "%.1fs", scrubPosition))
|
||||
.scaledFont(size: 10, weight: .medium, design: .monospaced)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 56, alignment: .trailing)
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { scrubPosition },
|
||||
set: { newValue in
|
||||
scrubPosition = newValue
|
||||
engine.setChapterPosition(newValue)
|
||||
}
|
||||
),
|
||||
in: 0...max(0.01, engine.currentChapterDuration),
|
||||
onEditingChanged: { editing in
|
||||
isScrubbing = editing
|
||||
if !editing {
|
||||
// Sync any drift after release.
|
||||
scrubPosition = engine.chapterPosition(at: Date())
|
||||
}
|
||||
}
|
||||
)
|
||||
.controlSize(.small)
|
||||
.tint(.white.opacity(0.9))
|
||||
|
||||
Text(String(format: "%.0fs", engine.currentChapterDuration))
|
||||
.scaledFont(size: 10, weight: .medium, design: .monospaced)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// MARK: - Speed slider (signed)
|
||||
|
||||
private var speedControl: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "backward.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { engine.speed },
|
||||
set: { engine.setSpeed($0) }
|
||||
),
|
||||
in: SceneEngine.speedMin...SceneEngine.speedMax
|
||||
)
|
||||
.controlSize(.small)
|
||||
.frame(width: 160)
|
||||
.tint(speedTint)
|
||||
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(speedLabel)
|
||||
.scaledFont(size: 10, weight: .heavy, design: .monospaced)
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 56, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
|
||||
private var speedTint: Color {
|
||||
if engine.speed > 0.05 { return .green.opacity(0.8) }
|
||||
if engine.speed < -0.05 { return .orange.opacity(0.8) }
|
||||
return .gray.opacity(0.8)
|
||||
}
|
||||
|
||||
private var speedLabel: String {
|
||||
if abs(engine.speed) < 0.05 { return "❚❚ 0×" }
|
||||
return String(format: "%+.2f×", engine.speed)
|
||||
}
|
||||
|
||||
// MARK: - Cast clusters / play / dots / settings
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
engine.togglePlay()
|
||||
} label: {
|
||||
Image(systemName: engine.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.frame(width: 32, height: 28)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
|
||||
private var navCluster: some View {
|
||||
HStack(spacing: 4) {
|
||||
navButton(icon: "backward.end.fill") { engine.goTo(global: 0) }
|
||||
navButton(icon: "chevron.left") { engine.previous() }
|
||||
navButton(icon: "chevron.right") { engine.next() }
|
||||
navButton(icon: "forward.end.fill") { engine.goTo(global: engine.totalScenes - 1) }
|
||||
}
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
showSettings.toggle()
|
||||
|
|
@ -69,22 +174,11 @@ struct GlassControls: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
engine.togglePlay()
|
||||
} label: {
|
||||
Image(systemName: engine.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.frame(width: 36, height: 30)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
|
||||
private func navButton(icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.frame(width: 28, height: 28)
|
||||
.frame(width: 26, height: 26)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
|
|
@ -95,20 +189,21 @@ struct GlassControls: View {
|
|||
let isCurrentChapter = chapter.id == engine.address.chapter
|
||||
let isPast = chapter.id < engine.address.chapter
|
||||
Circle()
|
||||
.fill(isCurrentChapter ? Color.white : isPast ? Color.white.opacity(0.6) : Color.white.opacity(0.2))
|
||||
.frame(width: isCurrentChapter ? 8 : 5, height: isCurrentChapter ? 8 : 5)
|
||||
.fill(isCurrentChapter ? Color.white
|
||||
: isPast ? Color.white.opacity(0.6)
|
||||
: Color.white.opacity(0.2))
|
||||
.frame(width: isCurrentChapter ? 8 : 5,
|
||||
height: isCurrentChapter ? 8 : 5)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text-scale popover
|
||||
|
||||
/// Continuous slider for the global text-size multiplier. Bound to
|
||||
/// `AppSettings.textScale`; every other view that uses `.scaledFont(...)` or
|
||||
/// `settings.scaled(...)` reacts immediately.
|
||||
private struct TextScalePopover: View {
|
||||
@Bindable var settings: AppSettings
|
||||
|
||||
|
|
@ -139,12 +234,8 @@ private struct TextScalePopover: View {
|
|||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
Text("A")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("A")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(.tertiary)
|
||||
Text("A").font(.system(size: 18, weight: .bold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue