This file is the single source of truth for any AI agent working on this codebase. Read it in full before making any changes.
| Field | Value |
|---|---|
| Name | TopPresenter |
| Platform | macOS 15.7+ |
| Language | Swift 5.0+, SwiftUI, SwiftData |
| Xcode | 16.3+ |
| Repo | https://github.com/RobyRew/TopPresenter |
| License | Apache 2.0 (see LICENSE + NOTICE) |
| Current version | 0.0.1 (pre-release; bumped to 1.0.0 only when explicitly asked) |
| Author | Cosmin Calin / RobyRew |
TopPresenter/
├── Core/
│ ├── AppState.swift @Observable — global navigation + alert state
│ ├── AppCommands.swift SwiftUI Commands (menu bar) + all Notification.Name constants
│ ├── Constants.swift WindowIdentifiers, SupportedBibleFormat, SupportedExportFormat,
│ │ USFMBookIDs, BibleBookCategory, PresentationDefaults
│ ├── DataMigration.swift SchemaV1 + TopPresenterMigrationPlan (SchemaMigrationPlan)
│ ├── LibraryManager.swift @Observable — Bible & Song navigation, search, verse caching
│ └── PresentationManager.swift @Observable — live output state, screen management,
│ freeze/black/clear, all display settings (UserDefaults)
│
├── Models/ All @Model (SwiftData)
│ ├── BibleModels.swift BibleModule → BibleBook → BibleChapter → BibleVerse
│ ├── SongModels.swift SongCollection → Song → SongVerse
│ └── PresentationModels.swift MediaItem, PresentationSlide, ServiceSchedule, ScheduleItem,
│ PresentationStyle, LiveContent
│
├── Services/
│ ├── Import/
│ │ ├── BibleImportProtocol.swift protocol BibleImporter + BibleImportResult structs
│ │ ├── SongImportProtocol.swift protocol SongImporter + SongImportResult structs
│ │ ├── ImportService.swift central coordinator — importer registry pattern
│ │ ├── DragDropImportHandler.swift classifies dropped files → .bible/.song/.media/.unknown
│ │ ├── TopPresenterBibleImporter.swift
│ │ ├── OSISBibleImporter.swift
│ │ ├── ZefaniaBibleImporter.swift
│ │ ├── MySwordBibleImporter.swift (SQLite via libsqlite3)
│ │ ├── USFMBibleImporter.swift (directory of .usfm files)
│ │ ├── UnboundBibleImporter.swift
│ │ ├── OpenSongImporter.swift
│ │ ├── OpenLyricsImporter.swift
│ │ └── PowerPointSongImporter.swift (native Swift ZIP/XML parser)
│ ├── Export/
│ │ └── ExportService.swift Bible (JSON/TXT/CSV) + Song (JSON/XML/TXT)
│ ├── Audio/
│ │ └── AudioPlayerManager.swift @Observable — AVAudioPlayer wrapper
│ └── Video/
│ └── VideoPlayerService.swift @Observable — AVPlayer wrapper
│
├── Views/
│ ├── Main/
│ │ ├── MainControlView.swift root window: sidebar + content + preview panel
│ │ ├── SidebarView.swift
│ │ ├── ContentAreaView.swift routes to the active module view
│ │ ├── PreviewPanelView.swift routes to the active preview panel
│ │ ├── QuickSearchOverlay.swift ⌘K global search
│ │ └── Panels/
│ │ ├── BiblePreviewPanel.swift
│ │ ├── SongsPreviewPanel.swift
│ │ ├── MediaPreviewPanel.swift
│ │ ├── SchedulePreviewPanel.swift
│ │ └── CustomSlidesPreviewPanel.swift
│ ├── Bible/ BibleView.swift, BibleExportSheet.swift
│ ├── Songs/ SongsView.swift
│ ├── Media/ MediaView.swift
│ ├── Schedule/ ScheduleView.swift
│ ├── CustomSlides/ CustomSlidesView.swift
│ ├── Presentation/ PresentationOutputView.swift, TextBoxLayout.swift
│ ├── Import/ BatchImportSheet.swift, BatchExportSheet.swift
│ └── Settings/ SettingsView.swift, KeyboardShortcutsSheet.swift
│
└── TopPresenterApp.swift @main — two WindowGroups (main + presentation-output), menu commands
@ObservableonAppState,PresentationManager,LibraryManager,AudioPlayerManager- Objects injected via
.environment(...)at the top level inTopPresenterApp.swift - Never use
@EnvironmentObject— this project uses the newer@Observable+@Environmentpairing
- All menu bar actions post
Notification.Name(all defined inAppCommands.swift) - Views subscribe via
.onReceive(NotificationCenter.default.publisher(for: ...)) - Do not call
PresentationManagermethods directly from commands — always go through notifications
- All persistent models are
@Modelclasses inModels/ - Schema version:
SchemaV1(1.0.0). Future schema changes must add a newVersionedSchematype and register a migration stage inTopPresenterMigrationPlan LibraryManagercaches sorted verses incachedSortedVerses— refresh by callingrefreshCachedVerses()viaselectedChapter.didSet
- Every
PresentationManagerdisplay property usesdidSet { UserDefaults.standard.set(..., forKey: "pm_\(property)") } - Keys are all prefixed
pm_to avoid collisions - Do not use
AppStorageor@AppStoragefor presentation settings — stick to thedidSetpattern
WindowIdentifiers.presentation = "presentation-output"— a plain, borderless, transparentWindowGroup- Window is configured in
TransparentWindowConfigurator(NSViewRepresentable insidePresentationOutputView) PresentationManager.movePresentationWindow(to:)finds the window byNSUserInterfaceItemIdentifier(WindowIdentifiers.presentation)- The window must never be made opaque — background transparency is intentional for projector overlays
- Window auto-opens on app launch (0.3 s delay in
MainControlView.onAppear)
- Escape → posts
.clearOutputnotification →clearOutput()onPresentationManager clearOutput()callshidePresentationWindow()whenisSingleScreenMode == true(single display)hidePresentationWindow()useswindow.orderOut(nil)— notdismissWindowshowPresentationWindow()useswindow.orderFront(nil)and is called at the start ofshowBibleVerse,showSongVerse,showCustomText, and whentoggleBlack()turns black on
- Built-in screen =
NSScreen.screens.first - External (target) screen =
NSScreen.screens.lastwhen more than one screen is available isSingleScreenMode = NSScreen.screens.count <= 1- On screen disconnect: configurable action (
doNothing/moveToAvailable/goBlack/ask) - Monitoring started in
MainControlView.onAppearviapresentationManager.startScreenMonitoring()
VideoPlayerService(@Observable) is created inTopPresenterApp.init(), injected via.environment(...)into both windows, and linked back viaPresentationManager.videoService(weak) soclearOutput()stops playback- "Play Video" in
MediaPreviewPanel→videoService.loadVideo(url:)+play()+pm.showVideo()(setsLiveContent.contentType = .media) - The output window renders
OutputVideoView(AVKitAVPlayerView,controlsStyle = .none) whencontentType == .media; it stays mounted under the black-screen overlay so toggling black doesn't tear down the player VideoPlayerServiceholdsstartAccessingSecurityScopedResource()for the whole playback; released instop()
PresentationPreviewCardpreviews the Bible verse selection by default- Non-Bible panels (Songs / Schedule / Custom Slides) must pass
pendingContent:(PendingContent(text:reference:subtitle:)) so the card previews their selection before it goes live — never rely on the Bible-selection fallback there - The preview card and
PresentationOutputViewmust stay layout-identical: both render every section inside the same normalizedTextBoxFrames
- EVERY text box (4 built-ins + custom) carries the same
BoxTextStyle:isCustomized == false(default) inherits global text settings + section defaults (styleDefaults(for:)— ref 55%/semibold, translation 35%/0.6 opacity, subtitle 40%/0.6); the UI "Personalizează textul" toggle callsenableStyleCustomization(for:)which SEEDS fields with current resolved values - Render exclusively through
resolvedStyle(for:)/outputStyle(for:)(frozen-aware) /resolvedCustomStyle(_:)— never read raw style fields in views - The old per-section properties (verseFontName, refFontWeight, showTranslationName, translationNameSizeRatio, …) are GONE; translation is a normal box (hidden by default, default frame top-left), subtitle hidden by default
- Sources support
date/timewith per-box formats (formattedClock); output wraps content in a TimelineView driven bypm.clockTickIntervalso clocks tick live
- EVERYTHING layout-related is per presenter.
LayoutProfile(frames, visibility, styles, sources/formats/static texts, custom text boxes, media boxes, boxOrder, background, ContentOptions, transitions) is stored per key inpm.profiles("bible" / "song" / "text", persisted as ONE JSON blob underpm_layoutProfiles). The old flat per-box UserDefaults keys are LEGACY — read once by the init migration, never written again pm.activeProfileKey= the profile being EDITED (right bar, preview Edit Mode, Editor de Teme). It follows the sidebar module (MainControlView.onChange(of: appState.selectedSidebarItem)) and the editor header has a segmented Biblie/Cântece/Slide-uri picker bound to it (plus a copy-from menu →copyProfile(from:to:))pm.outputProfileKey= the LIVE content's profile; output render paths useoutputOrderedBoxTokens()/outputBoxFrame/outputStyle/outputSectionVisible— never the active-profile accessors- ALL box accessors take a trailing
in key: String? = nil(nil → activeProfileKey):boxFrame(for:in:),setSourceRaw(_:for:in:),isSectionVisible(_:in:), custom/media CRUD,orderedBoxTokens(in:), … Mutations route throughmutateProfile(_:_:)which registers undo + persists. The flat compat properties (verseBoxFrame,customTextBoxes,boxOrder,contentBackgrounds,contentOptions, …) are computed views over the ACTIVE profile — fine in operator UI, NEVER in output render paths relevantSections(for:)decides which built-in boxes a presenter offers (song = verse+reference+subtitle+chords, NO translation; text = verse+reference).canonicalTokens/orderedBoxTokensenforce it, so songs never see Bible-only casete (and bible/text never see the chords box —defaultfilters out.chords)- Chords casetá + transpose (v10):
TextBoxSection.chordsis a song-only built-in box (default OFF, default frame =.defaultChords= the verse area, so it's "tied to the verse"). It renders a chord-over-lyric chart, NOT plain text —PresentationOutputView.sectionBox, the editorsampleContent, and the preview card all special-casesection == .chords→ChordChartText(lines: pm.transposedSongLines(), …)(monospaced layout so a chord atposlands above lyric charpos). Chord data flows:SongSlide.lines(rich[SongLine], chunked in lockstep withtextbysplitToSlides) →showSongVerse(…, lines:)→LiveContent.songLines; paths that only carry text userichLines(forSlideText:in:)to recover chords by line-text match. Transpose/capo are display-only, ephemeral PM state (chordTransposeSemitones,chordCapo, pinned per-song viachordTransposeSongKey; reset on song change inshowSongVerseviasyncChordTranspose; never mutate the stored song).ChordTransposer(pure, tested) does root/quality/bass parsing, enharmonic spelling per target key, semitone math, capo shapes + suggestions, andrecommendedKeys(fromExtensionsJSON:). Operator UI =SongChordControl(popover in the song detail header, shown whensongHasChords): ±transpose, key picker, capo + suggested shapes, recommended-key chips, and an "Arată pe ecran" toggle that flips.chordsvisibility in the song profile.sectionText(.chords)returns the lyrics only to GATE mounting (non-empty == there's a chord slide); the chart readssongLinesdirectly. No overlap: when the chords box is active (chordsReplaceVerse(in:hasChartLines:)) the verse box is suppressed in every render path (the chart already shows the lyrics). Independent chord font (v10.1): the lyrics use the box's mainBoxTextStyle; the chord LETTERS use a SECOND style stored under the reserved keychordRowinLayoutProfile.styles(chordRowStyle/setChordRowStyle/resolvedChordRowStyle/outputChordRowStyle). The editor'sselectedBoxStyleGroupshows TWOtextStyleGroups for.chords(Versuri + Acorduri litere).ChordChartTexttakeslyricStyle+chordStyle, measures the lyric prefix width (AppKitNSString.size) to position each chord, so alignment holds for ANY lyric font + any chord size; it auto-fits via one scale factor - Song repeat markers (v10.1) — combinable bracket + count:
applyRepeatMarker(_:count:bracket:countStyle:)+applyRepeatMarkerRich(...)apply a BRACKET (song_repeatBracket: none/slash/bar/pipe — wraps first/last line, shifting first-line chord positions) AND a COUNT (song_repeatCount: none/times/bister —(×N)/bis/ter appended INLINE to the last line), so they combine: "‖: … :‖ (×2)". Both gate onsection.repeatCount > 1; line count is unchanged sotext+richLineschunk identically insplitToSlides. Count defaults totimesso a ×N section shows immediately.resolveRepeat(versionStyle:globalBracket:globalCount:)maps the single per-versionrepeatStyleoverride (slash/bar/pipe→bracket, times/bister→count, none→both off, ""→inherit). All paths decorate: the filmstrip/buildSongSlides(…bracket:countStyle:)and — crucially — the live verse-navigation path (SongVerseControlsBar/SongsPreviewPanel) viadecoratedVerse(_:version:bracket:countStyle:), which the marker-lessSongVersecache otherwise skips. Settings UI = two pickers (Paranteze + Repetări) in the song-options panel - The preview card (
PresentationPreviewCard) renders with the explicit keyactiveContentKey(live key when live, else the panel'sformatHint) — passin: keyeverywhere there - Per-profile transitions — THREE phases:
transitionInRaw(first appearance),transitionChangeRaw(Intermediar — slide → slide),transitionOutRaw(clear), +transitionDurationOverride(−1 = global duration).pm.contentChangeKind("appear"/"change"/"clear", set byregisterContentChange()in the show* methods andclearOutput) decides which phaseboxTransition(in:)builds. Catalog intransitionOptions(14: none/fade/zoomIn/zoomOut/slide×4/riseSoft/dropSoft/blur/blurZoom/fall/flip). Output applies.id("\(token)|\(text)")+.transition(...)per box inside.animation(..., value: liveFingerprint). Editor UI lives in the Tranziții tab — selecting any effect (or its play button) demos it on the canvas viaplayTransitionPreview(.id(transitionPreviewTick)+ the chosen.transition; commit the raw async-first or the removal plays the old effect) - Slide scope ("Afișare"): every box can show on all/first/last slides (
LayoutProfile.displayOnper section,displayOnRawon CustomTextBox + MediaBox).LiveContentcarriesslideIndex/slideCount(every show* caller passes them: song verse position, slide deck position, schedule item position); gate rendering withpm.scopeMatchesLiveSlide(_:)— single-slide content counts as BOTH first and last. Use case: song title only on the first slide, "Amin." only on the last - Per-presenter casete naming + sources:
TextBoxSection.label(for key:)(Songs: Versuri/Titlu Cântec/Etichetă Strofă; Slides: Conținut/Titlu Slide) andsourceOptions(for key:)/sourceOptionLabel(_:for:)— song sources have no translation, all keys offer static/date/time/slideNumber ("2 / 7", resolved viaLiveContent.slideNumberText).CustomTextBox/MediaBoxnow have resilientinit(from:)— keep decodeIfPresent when adding box fields - Editor tab layout (v8/v8.2/v8.3): Layout tab = position/content/Afișare only; the per-box "Personalizează textul" style group lives in the Text tab (
selectedBoxStyleGroup) under Text Global, and BOTH lists have the SAME 12 options in the SAME order: Font, Mărime, Greutate, Culoare, Aliniere, Vertical, Opacitate, Spațiere, Transform.(menu picker, NOT segmented — 4 segments overflow the 310pt inspector and center-clip the group), Padding, Umbră, Auto-fit — keep that parity when adding options. Per-box inherit sentinels: padding −1, shadowMode ""(global)/"on"/"off" + shadowRadius −1, autoFitMode ""/"on"/"off". Group inner VStacks carry.frame(maxWidth: .infinity, alignment: .leading)so an over-wide row can't center-clip the content - Per-box padding/shadow/auto-fit are RESOLVED STYLE fields:
ResolvedBoxStyle.padding/shadowEnabled/shadowRadius/autoFit— render paths take them from the style, never frompm.padding/pm.shadowEnableddirectly (the oldscaledPaddingparameter is gone); auto-fit applies to ANY box whose style asks (global Auto-fit toggle still means verse box only by default) - Text transforms (v8.2) — STYLE-level, not field-level:
displayFieldsis GONE.BoxTextStyle.transformRaw("" = inherit) resolves intoResolvedBoxStyle.transformRawviadefaultTransform(for:in:)(profileoptions.textTransformRaw= the Text Global "Transform." picker, applies to ALL boxes; legacyreferenceUppercasestill forces "upper" on the reference section). EVERY render path drawsText(style.display(text))— never raw text.resolvedCustomStyle(_:in:)needs the profile key.BoxTextStylehas a resilientinit(from:)— keep decodeIfPresent when adding fields - Inspector structure (v8.1/v8.2) — NO quick-actions bar: the
caseteGroup(z-order list + add buttons + undo/redo) is PINNED above the inspector tab picker; the ROWS scroll inside the group (~3.5 visible,rowHeight 27, no dead space below); quick-align toggle buttons live inside Layout ▸ Poziție și Dimensiune; "Resetează Layout" sits at the bottom of the Layout tab. Don't reintroduce a toolbar row above the canvas - Show/Hide staging (v8.5) — transitions must actually RENDER:
presentContent(_:)wraps every show*: if the output window was hidden (single-screen idle) it orders the window front and mounts the content 60 ms LATER insidewithAnimation(easeInOut(phaseDuration))— otherwise Intrare pops fully formed (a nil window, e.g. unit tests, applies immediately).clearOutputanimates the clear with the Ieșire duration and, in single-screen mode, hides the window only AFTERexitDuration + 0.15s(guarded on still-not-live) — hiding immediately cut the exit animation AND left stale boxes that made the next Show crossfade like an Intermediar. The output's backgroundLayer is gated onliveContent.isLivewith.transition(.opacity)so the background fades from/to transparency with Intrare/Ieșire (idle output = fully transparent) - The global
transitionDurationhas NO UI anymore (removed from right bar ▸ Ieșire and Settings ▸ Comportament) — it survives only as the stored fallback base under the per-phase sliders. Don't resurface it; durations are edited per phase in Editor de Teme ▸ Tranziții - v9 (universal themes + text engine): the Teme gallery is UNIVERSAL — every panel shows every theme (a theme carries all presenter profiles); the
formattag is only the default for newly saved themes + a badge. The gallery click-drag pans (ScrollPosition+onScrollGeometryChange+DragGesture(minimumDistance: 12)so taps/hover survive). Text-engine invariants:font(at:)MUST apply.weight()to custom fonts too (Greutate was a no-op for any non-System font);resolve()'s NOT-customized branch usesglobalVAlignRawdirectly (a stale seededvAlignRawused to stick after un-customizing); new globalsletterTracking(pt @1080p,.tracking(style.tracking * fontScale)in every render path) andshadowColorHex(8-digit RRGGBBAA viaColor.toHexWithAlpha(), alpha = intensity) — both in ThemePayload/capture/apply/init; per-boxtracking: Double?(nil = global) +shadowColorHex("" = global). Ranges: font ≤200 (maxFontSize), opacity 0–1 step 0.01 (rounded % display), line spacing 0–5 both levels, padding 0–300, shadow radius 0–50. Option order is now: …Opacitate, Spațiere, Litere, Transform., Padding, Umbră(color+radius), Auto-fit. Afișare scopes are per key (displayScopeOptions(for:)): songs add Refren/Strofe (chorus detection =LiveContent.isChorusSlide, diacritic/case-insensitive prefix refren/chorus/cor on the subtitle label). Casete list shows 4 rows; the row's drag/tap surface is ONLY the leading label area — eye/trash buttons sit outside it (18×18 hit areas) so clicks are never swallowed - v8.4 polish: EVERY box row has eye + trash (built-ins HIDE — the eye re-enables; custom/media delete) and the same Elimină/Șterge in both context menus. The list color swatch is a
BoxColorSwatchbutton (hover ring, popover ColorPicker, "Culoarea implicită" reset) backed byLayoutProfile.boxColorstoken-keyed;boxColor(for:pm:)resolves custom-then-default — editor chrome only, never rendered output.lastLiveProfileKeykeepsoutputProfileKeyon the LAST presented profile after Hide/Clear/ESC so the Ieșire transition (content → transparency) plays with the right profile's effect. Tranziții UI: group is named "Global", each phase has a DIRECT 0–3 s Durată slider writing the phase override (no checkboxes, no general duration row — the global base stays in the right bar ▸ Ieșire); the per-casetă group mirrors that (direct Durată + Întârziere 0–3 s) - Per-box transitions (v8.2):
LayoutProfile.boxTransitionOverrideskeyed by z-order TOKEN holdsBoxTransition(isCustomized gate + own in/change/out effects with "" = inherit,delaystagger,duration−1 = inherit). Per-PHASE durations:transitionInDuration/transitionChangeDuration/transitionOutDuration(−1 = profile general). Resolution order: box override → phase override → profiletransitionDurationOverride→ global.boxTransition(in:token:)resolves it all; a box with its own delay/duration carries its own.animation(...)clock; output's container animation usesresolvedTransitionDuration(in:). UI: Tranziții tab = "General" group (3 effect rows + per-phase "Durată proprie" checkboxes + general duration toggle) + per-SELECTED-casetăboxTransitionGroup("Personalizează tranziția"). Setting a pristine override DELETES the dict entry - Theme hover preview: resting on a
ThemeCardfor 350 ms applies the theme TRANSIENTLY (beginThemeHoverPreview/endThemeHoverPreview— snapshot + applyPayload, never registers undo, restores on unhover/onDisappear). It is a NO-OP whileliveContent.isLive(the projector must never flicker), andapplyThemecallsendThemeHoverPreview()first so undo captures the true previous look - ThemePayload carries
profiles(+ global text/background); legacy flat payloads decode viaLegacyKeysinto identical per-presenter profiles..tpthemev2 asset slots: "background", "profileBackground:", "mediaBox::" (v1 "contentBackground:" and "mediaBox:" still import)
- Backgrounds support the full media trio (image / animated GIF / looping muted video) at BOTH levels: global (
backgroundMediaTypeRaw+backgroundMediaURL) and per-content (BackgroundConfig.mediaTypeRaw). Render viaactiveBackground(for:frozen:)+BackgroundMediaView(plays on output/editor, thumbnail in the preview card) — never read backgroundEnabled/backgroundImage directly in render paths - Bookmarks: ALWAYS use
PresentationManager.makeBookmark(for:)/resolveBookmark(_:)— they try security-scoped first and fall back to plain (app-container files have no scope) - Themes (
ThemewithformatRaw"all"/"bible"/"song"/"text") snapshot the ENTIRE look; the panel footer hosts a THUMBNAIL GALLERY (ThemeGalleryView, filtered by the panel's format + universal themes), with card context menus (apply/update/rename/format/export/delete);ThemeMenuControlremains in the editor header - Decoding is resilient:
ThemePayload/Theme/BackgroundConfig/ThemeArchiveuse decodeIfPresent with defaults — adding payload fields never breaks stored themes again. Keep this invariant when adding fields - .tptheme import/export: directory package (theme.json
ThemeArchivev2 + media/ with every referenced file). Export strips bookmarks and embeds files; import copies media into the app container (themeMediaDirectory(for:)) and re-bookmarks — themes are fully portable. UTIcom.robyrew.toppresenter.themedeclared in Info.plist - The editor is called "Editor de Teme" everywhere (sheet title, toolbar, menu, footer button)
- Editor tabs: Layout / Text / Fundal / Tranziții — NO output/hardware settings in the editor; screen/window-level/transition/disconnect live in Settings (⌘,) ▸ Proiecție (
ProjectionSettingsTab) AND compactly in the right bar's Ieșire disclosure (StyleQuickSettings.output, beneath General). Themes describe the LOOK, Settings describe the DEVICE - Per-presenter options (
ContentOptionskeyed "bible"/"song"/"text", theme-persisted, resilient decoding): text transform (none/upper/lower), uppercase reference/title. Applied at RENDER time viapm.displayFields(main:reference:translation:subtitle:contentKey:)— output uses the live content key, the preview card uses its panel'sformatHint. Extend ContentOptions (with decodeIfPresent defaults) when a presenter needs a new option - Media module output prefs (NOT theme):
videoLoopsByDefault,fullscreenVideoFillRaw— live in Settings ▸ Proiecție ▸ Media - Toolbar rules: per-view items are conditional on
appState.selectedSidebarItem; the Media filter Picker binds@AppStorage("mediaTypeFilter")which MediaView reads (never write UserDefaults directly from toolbar bindings); Freeze sits next to Black/Clear in the presentation group - Sidebar (v10.2) =
SidebarItem.contentItems(bible/songs/media/schedule/customSlides) in the topList, + a PINNED bottom group (utilityItems=.history,.accountas selectable destinations, plus a Settings button via@Environment(\.openSettings))..history→HistoryView,.account→ProfileView(local prefs,@AppStorageonly — no login) route throughContentAreaView; both returnEmptyViewinPreviewPanelViewand the preview column is HIDDEN for them inMainControlView(full-width). Any new switch overSidebarItemmust handle.history+.account - Single output window: locate it via
presentationWindows(plural) and calldedupePresentationWindows()(closes extras) at the top ofshowPresentationWindow/movePresentationWindow/positionOnScreen+ after the launch auto-open (guarded byhasPresentationWindow); the presentationWindowGroupis.restorationBehavior(.disabled). This killed the "two overlapping outputs" (state-restoration + auto-open) bug — don't reintroduce an unguardedopenWindow(.presentation) - Song verified flag + edit log (v10.2):
Song.verified(Bool, round-trips through GOAT —songDictV2writes"verified",TopPresenterSongImporterreads it),Song.modifiedDate(drives the Recente sort),Song.editLogJSON→editLog: [SongEditEntry](coarse change log, INTERNAL — not exported). The song editor snapshots the song to GOAT on open (ExportService.exportSongToTopPresenterJSON); Renunță reverts viaImportService.applyResult(_:to:modelContext:)(the GOAT→Song builder extracted fromcreateSongFromResult— clears + rebuilds versions/sections, reused by import too); Gata diffs old↔new viaImportService.summarizeChanges(old:new:)→ appends edit-log entries.SectionEditorCarduses@FocusStateso clicking a section drives the editor preview. Library: verified badge insongBadges, "Doar verificate" filter +verificat/✓ search token, sort header chips (SongSortKey= A-Z/Artist/Carte/Limbă/Recente). Song slide thumbnails have PREVIEW + trash (delete = remove the section behind the slide,.confirmationDialog) - Unified z-order for EVERY box (sections + custom + media interleaved): per-profile
boxOrdertoken list ("section:" / "custom:" / "media:"), reconciled viaorderedBoxTokens()(pure — safe in view body; new boxes land on top, media defaults to the back). ALL render paths (outputorderedBoxes, preview card, editor canvas) iterate this order — never hardcode section/media layering again. Reorder via drag in the Casete list (front-first,reorderBoxToken(_:above:)) or the Ordonare context menu on any box (canvas + list) - Custom + media boxes are renamable (
namefield, context-menu Redenumește); translation & subtitle rows have a trash button that HIDES them (built-ins are never deleted) - Hidden boxes are COMPLETELY invisible everywhere — preview card AND editor canvas pass
showsHiddenBoxes: false; the only place a hidden box appears is the Casete list (dimmed, eye to re-enable) - The per-box Vertical picker lives INSIDE the "Personalizează textul" toggle (with a Global segment); non-customized boxes inherit
globalVAlignRaw - The GLOBAL text palette includes weight (
globalWeightRaw— inherited by every section whose design default is regular), vertical alignment (globalVAlignRaw— inherited when a box'svAlignRawis empty), and opacity (globalTextOpacity— multiplied into non-customized boxes). Every option must exist at BOTH levels — never add a per-box style control without its global counterpart - The Fundal tab shows the global background + the EDITED profile's own background only — switch profiles in the editor header to set the others
- Each main window/tab owns its OWN
AppState+LibraryManager(created inMainWindowRoot) — different tabs can browse different modules with different Bible sources.PresentationManager/audio/video are app-global: ONE output, whichever tab presses Show drives it - File ▸ Filă Nouă (⌘T) opens a new window that joins as a native tab (
tabbingMode = .preferredset inWindowReader); capped at 10 main windows - Notification handlers in window-hosted views MUST use
.onKeyWindowNotification(_:perform:)(WindowNotifications.swift), never raw.onReceive— otherwise every tab reacts to every menu command. Output-wide commands (black/freeze/clear/font size) are handled ONCE byPresentationCommandRouter(created in App.init), never per window - NEVER use a customizable toolbar (
.toolbar(id:)) on the tabbed main window — customizable toolbars sync items across the window-tab family via the customization plist, and the second tab re-inserts NavigationSplitView's sidebar toggle →NSToolbar duplicate itemassertion CRASH. The main toolbar must stay a plain.toolbar { }
- Snapshot-based (
registerLayoutUndo()called at the top of every box mutator; snapshots reuseThemePayload); registrations <0.8s apart coalesce so a drag = one step;applyPayloadsetsisRestoringLayoutso restores never re-register; undo/redo buttons live on the "Casete" group title in the editor. New box mutators MUST callregisterLayoutUndo()first
- Four FIXED built-in text boxes — verse content, reference/title, translation name, subtitle — each a
PresentationManager.TextBoxFrame(normalized 0…1 x/y/width/height of the target screen), plus user-createdCustomTextBoxes (own text + style, stored in each profile) - Boxes never move or resize with their content. Text is laid out INSIDE its box (horizontal alignment from text settings, per-box vertical alignment
pm_verseVAlign/pm_refVAlign);paddingis the inner horizontal inset - Persisted inside the profile blob (
pm_layoutProfiles); always go throughboxFrame(for:)/setBoxFrame(_:for:)— overloads takeTextBoxSectionorBoxIdentity(.section(...)/.custom(UUID)) and clamp viaTextBoxFrame.clamped(); freeze snapshots the frames (and custom boxes) like every other display setting - Resolution adaptivity: font sizes are authored at a 1080-point reference height (
PresentationManager.referenceScreenHeight) and multiplied byfontScale(forHeight:)/targetFontScaleat render time. Normalized boxes + scaled fonts = the layout adapts automatically to any resolution / aspect ratio / PPI. Auto-fill must pass SCALED font/padding (pm.fontSize * pm.targetFontScale) fittedVerseFontSize(text:boxSize:maxSize:padding:)expects screen-scaled maxSize/padding; reference/translation/subtitle/custom boxes useminimumScaleFactorinside their boxes- Bible auto-fill measures against
pm.verseBoxPointSize—LibraryManager.versesCountThatFits(screenSize:)expects the verse-box point size, not the screen size - The old per-section offset/scale/padding transforms and the
VerseTextRenderertext-bounds overlay are GONE — do not reintroduce content-driven box geometry
LayoutEditorSheetinTextBoxLayout.swiftis THE home for all styling: canvas (drag/resize/click-select boxes, right-click context menus, arrow-key nudge 1%/⇧5%, quick-align TOGGLES that restore the previous frame on second press) + tabbed inspector — Layout / Text / Fundal / Tranziții- Opened via: toolbar "Layout Editor" button, the
LayoutEditorButtonfooter in every preview panel, or Presentation ▸ Layout Editor… (all post.openLayoutEditor) - The right preview panel is OPERATIONAL ONLY — preview, navigation, Show/Hide/Black/Freeze/Clear, audio/video transport, Multi-Verse + General quick toggles. New style settings go in the Layout Editor inspector, never back into
StyleQuickSettings - Edit Mode (toolbar toggle) shows the drag/resize overlay on the preview card; fine editing happens in the Layout Editor
- Every box shows its DATA SOURCE (inspector "Sursă:", box tooltip, context-menu header). Built-in sections can be hidden (per-profile
visibility); custom boxes support duplicate/delete - Sources are configurable on EVERY text box: built-in sections default to
"auto"(their natural field — keep that default) but can be overridden viapm.sourceRaw(for:)/setSourceRawto any live field (mainText/reference/translation/subtitle), static text (pm.staticText(for:)), or date. Custom boxes default to"static". All rendering goes throughpm.sectionText(_:main:reference:translation:subtitle:now:in:)/CustomTextBox.resolvedText— output passes live values, preview passes its preview values, editor passes samples. A non-"auto" translation-box source bypasses the showTranslationName/isBible gate - Media boxes (
PresentationManager.MediaBox, stored per profile): image/GIF/video overlays with opacity, corner radius, edge feather (blurred-mask border fade), fit/fill, andshowOnRawcontent filters (always/bible/song/text). Rendering inMediaBoxViews.swift— GIFs animate via NSImageView (animates = true), videos loop muted via AVQueuePlayer+AVPlayerLooper and PLAY ONLY on the real output (preview/editor show placeholders) - Picker gotcha: never attach
.help()(or other modifiers) to tagged segmented-picker items — it breaks tag matching and the tabs stop switching - Drag gotcha: box drag/resize gestures MUST measure in the overlay's named coordinate space (
TextBoxEditOverlay.canvasSpace) — measuring in the moving view's own space feeds back into the gesture and the box jitters/shakes
- The app is sandboxed (
com.apple.security.app-sandbox); any user-chosen file that must survive relaunch needs a security-scoped bookmark, not a raw path - Background image: bookmark stored under
pm_backgroundImageBookmark(set insetBackgroundImage(from:), removed inremoveBackgroundImage()) - Media files:
MediaItem.bookmarkData/resolvedURL
- NEVER spawn child processes (ditto, unzip, …) to read user-selected files — children of a sandboxed app do NOT inherit the user's file-access grant, so extraction fails. PPTX is read in-process via
ZipArchiveReader(Services/Import) — central directory + stored/deflate entries through the Compression framework (COMPRESSION_ZLIB== raw DEFLATE) - Import file pickers (Bible + Songs) are intentionally UNRESTRICTED (no allowedContentTypes) — the selected format decides parsing; restricting types made .pptx unselectable. Keep them unrestricted
- TopPresenter Bible JSON (
schemaVersion: "1.0.0") is the superset of every format. All rich fields are OPTIONAL (empty when a source lacks them);textis always present for display/search. Decoding is version-agnostic + resilient (decodeIfPresent) — the importer keys on field presence, never onschemaVersion, so older/plainer files still import. - Per-verse:
text,runs?[]({text, kind, strong?, morph?, gloss?}, kind =plain|woc|add|divineName|quote— carries red-letter + italics + Strong's + interlinear gloss at sub-verse granularity, concatenation reproducestext),footnotes?[],crossReferences?[]({label?, targets[]}; legacy{references[]}still decodes),hasWordsOfChrist,gloss(verse-level interlinear reading). Per-chapter:headings?[]({beforeVerse, level, text}). Per-book:nameEnglish,abbreviation,introduction. Per-translation:versification,canon,nameLocal,languageName,copyright,about(foreword essays),source,year,direction,hasWordsOfChrist,hasStrongs,incomplete. Every level also carries_extensions(stored asextensionsJSON) so unknown/future fields round-trip. The SwiftData model stores the COMPLETE superset losslessly (BibleModule/Book/Chapter/Verse extended 2026-06-16; all additive optionals → lightweight migration); rich arrays as JSON strings (runsJSON/footnotesJSON/crossRefsJSON/headingsJSON); shared Codable types (VerseRun/BibleHeading/BibleFootnote/BibleCrossRef) inBibleImportProtocol.swift;BibleRichData.encodestashes them. Import → store → re-export is fully lossless; other formats (OSIS/USFM/MySword/Zefania) import/merge into the same DB and re-export astoppresenter_json. - Casete (box) Bible sources (
PresentationManager.sourceOptions"bible" case →resolveBoxSource→LiveContent): beyondmainText/reference/translation/subtitle, the Bible profile offersheading,footnote,crossReference,gloss,strongs.LiveContentcarries these (populated byshowBibleVerse(...));LibraryManager.selectedVerses{Footnotes,CrossRefs,Heading,Gloss,Strongs}derive them for the live selection. - Format feature matrix (importers now CAPTURE these instead of stripping): headings (OSIS
<title>, USFM\s, eBibliaheadings); red-letter (OSIS<q who="Jesus">, USFM\wj, eBiblia<span class='Isus'>— all wired intoruns[]; Zefania/MySword pass through plain — future); footnotes/cross-refs/Strong's are schema-ready.ExportService.exportToTopPresenterJSONemits the full v2 schema — import any format, re-export the GOAT. - eBiblia data layer (reverse-engineered live, 2026-06-16): verses
eb<code>:BB:CCC:VVV; extraseb<code>-res:…with key suffixest(heading)/x(cross-ref)/f(footnote); front matter/foreword in the singleebart:b:<code>article (no separate book-intro keys); name inebart:b:t:<code>. Verse markup variants the scraper'sparseRichVersehandles:<span class='Isus'>→woc,<em>→add, inlineword<sr>G..</sr>→Strong's (KJV), interlinear<i><wd>W</wd><sr>S</sr><mf>M</mf></i>(astl) and<i><wd>W</wd><sr>S</sr><en>gloss</en></i>(enint)→runs withstrong/morph/gloss.<sr>/<mf>strong+morph exist in KJV and the whole interlinear family, not just interlinears. - Red-letter theme (
PresentationManager.wocStyleEnabled+wocColorHex, theme-persisted, Bible profile only): the output verse box composesLiveContent.mainRunsand colorskind == "woc"runs; the verse-show path threadsruns:from the selectedBibleVerse(single-verse only; multi-verse blocks render plain). Editor row in Text tab. Populated by OSIS/USFM and the eBiblia scraper v1.15.0 (<span class='Isus'>→woc,<sr>→strong,<mf>→morph,<en>→gloss). - Duplicate-on-import (
ImportService):existingBibleModule(code:)+BibleConflictResolution(ask/replace/merge/keepBoth/cancel)..askthrowsBibleConflict(with stats) for the UI dialog;.mergefills only missing books/chapters/verses (existing verses win);.keepBothdisambiguates the name. BibleView shows the dialog; batch/drag-drop default to.keepBoth(non-destructive).
- Create
Services/Import/MyFormatImporter.swift - Conform to
BibleImporter— implementformatandparse(fileURL:) async throws -> BibleImportResult(populate the optional rich fields where the format provides them) - Add the format case to
SupportedBibleFormatinConstants.swift - Register in
ImportService.bibleImporters
Same pattern — conform to SongImporter, add to SupportedSongFormat, register in ImportService.songImporters.
- Run unit tests with
-only-testing:TopPresenterTests— the UI test target launches the real app and needs Accessibility permissions (it fails/hangs headless) - Test targets MUST carry
DEVELOPMENT_TEAM = FJHAUWNNBHlike the app target; without it the xctest bundle is ad-hoc signed and dlopen rejects it ("different Team IDs") - If results look stale (old failures at shifted line numbers, missing new tests),
touchthe test file and rebuild — Xcode occasionally reuses a stale test bundle
- Every push to
maintriggers thepre-releasejob in.github/workflows/build-and-release.yml - Tag format:
v{MARKETING_VERSION}-alpha.{GITHUB_RUN_NUMBER}(e.g.v0.0.1-alpha.7) - Each prerelease is unique — old ones are never deleted or overwritten
- Pre-release series:
0.0.1,0.0.2,0.1.0, …
- Bump
MARKETING_VERSIONinTopPresenter.xcodeproj/project.pbxprojto the final version (e.g.1.0.0) - Commit and push
- Tag and push:
git tag v1.0.0 && git push origin v1.0.0 - The
releasejob fires only for tags that do not contain-(e.g.v1.0.0qualifies;v0.0.1-alpha.7does not)
xcodebuild \
-scheme TopPresenter \
-project TopPresenter.xcodeproj \
-configuration Release \
-derivedDataPath build \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
DEVELOPMENT_TEAM="" \
clean buildUnsigned builds require users to right-click → Open, or run xattr -cr TopPresenter.app.
| Shortcut | Action |
|---|---|
⌘K |
Quick search |
⌘B |
Black screen |
⌘F |
Freeze / Unfreeze |
Return |
Show / Hide content |
← → |
Navigate verses / slides |
Escape |
Clear output (hides window on built-in screen) |
⌘+Click |
Multi-select verses |
⇧⌘Escape |
Clear All |
⌘1–5 |
Navigate to Bible / Songs / Media / Schedule / Custom Slides |
⌘I |
Import Bible |
⇧⌘I |
Import Songs |
⌘E |
Export Bible module |
⇧⌘E |
Batch Export |
⌘+ / ⌘- / ⌘0 |
Increase / Decrease / Reset font size |
⇧⌘P |
Start Presentation |
⇧⌘K |
Keyboard shortcuts reference |
- All user-visible strings use
String(localized: "...", comment: "...")— never raw string literals - Locales in
i18n/locales/:en,ro(Romanian is the primary deployment language) - Alert strings in
AppState.showError/showSuccessmust be localized - One existing Romanian string slipped into
MainControlView:"Ecran Deconectat"— leave it, it's intentional
- Do not add
@AppStorageor@Statefor presentation display settings — usePresentationManager+UserDefaultsdidSet - Do not use
dismissWindow— window visibility is managed byorderOut/orderFrontdirectly - Do not make the presentation output window opaque
- Do not delete or merge the
betatag — it no longer exists; pre-releases use numbered alpha tags - Do not use
@EnvironmentObject— use@Environmentwith@Observable - Do not add
NSPersistentContaineror CoreData — SwiftData only - Do not hardcode screen indices — always use
NSScreen.screensdynamically - Do not skip
security-scoped bookmarkhandling for media files —MediaItem.resolvedURLhandles this - Do not give toolbar/panel buttons keyboard shortcuts already owned by a menu command — the menu always wins and the button shortcut is silently dead (this is why Edit Mode has no ⇧⌘E)
- Do not call
NSApp.sendAction(Selector(("showSettingsWindow:")))— use@Environment(\.openSettings)
| Export type | JSON field | Value |
|---|---|---|
| Bible | "format" |
"TopPresenter Bible" |
| Songs | "format" |
"TopPresenter Songs" |
All TopPresenter exports embed this identifier so importers can reliably distinguish them from generic JSON.
This file must be updated whenever any of the following change:
- A new architectural pattern is introduced or an existing one is changed (e.g. a new observable class, a new notification name, a new persistence model)
- A new importer/exporter format is added (update the File Format Identifiers table + the Importer section)
- A new keyboard shortcut is added or an existing one is remapped
- A new
@Modeltype is added to the SwiftData schema (updateDataMigration.swiftsection + models list) - A new screen management rule is established
- A "What NOT To Do" rule is discovered (e.g. after a painful bug or regression)
- The deployment target, Xcode version, or Swift version changes
- The versioning or release process changes
- A new localization locale is added
- Any important constraint or behaviour is explained verbally in a chat — if it's worth saying once, write it here so it doesn't need to be said again
When in doubt: add it. Future agents and contributors will thank you.