Skip to content

Latest commit

 

History

History
1619 lines (1404 loc) · 93.2 KB

File metadata and controls

1619 lines (1404 loc) · 93.2 KB

tie-gui — LLM Reference

Monorepo of GUI clients for tie (triple store + content-addressed storage; data organized by tags). Most apps share a vendored Fyne fork — hence the monorepo.

Repository layout

Path What
cmd/imgview/ Local-filesystem image viewer entry point
cmd/tie-view/ tie-network image viewer entry point (also opens local directories/images/archives like imgview)
cmd/tie-fm/ Twin-panel file manager (local files ↔ tie), folded in from the standalone tie-fm repo; imports the shared tagselection widget (its old vendored copy was deleted). Dot-files (leading .) are hidden by default; the checkable Menu item "Show hidden files" toggles Config.ShowHidden and reloads both panes (visibleEntries, applies to every provider)
cmd/tie-fm/internal/ tie-fm internals: config, fs (local/tie/mtp providers), ui (incl. preview.go: per-pane thumbnail grid embedding gallery), widget/tablewidget
cmd/tie-audio/ Tag-driven audio player entry point (internal/ has its own config/data/playback/ui)
gallery/ Shared library: layout engine, tile widget, image view, config
gallery/gallery.go Gallery controller (renamed from imageviewer.go in Phase 1)
gallery/imageview.go Single-image display widget
gallery/imageinfo.go Per-item data model (extracted from tilelayout.go in Phase 1)
gallery/pagination.go Elided bottom-bar page links (2-6 by width) + grid sizeWatcher
gallery/infooverlay.go Single-image metadata/EXIF overlay (I key, ☰ menu)
gallery/helper.go File-type detection utilities (extracted from imageview.go in Phase 1)
gallery/apphelper.go Shared app bootstrap helpers (Phase 4)
gallery/platform.go Mobile vs desktop platform abstraction (Phase 5) + CompactLayout width threshold
gallery/drawer.go Slide-over sidebar drawer (SidebarDrawer), the phone alternative to the sidebar HSplit; Gallery.DrawerObject() exposes the overlay so an app-level view can stack the same drawer over its own content
gallery/filterchips.go Filter summary chip row above the grid (SetFilterChips, TagFilterChips); FilterChipRow renders a chip list for app-level views that replace the grid
gallery/extension.go Extension interface documentation (Phase 6)
tagselection/ Tag-picker widget used by tie-view sidebar, image tagger, and tie-fm tag panel
tagselection/trie/ 256-ary prefix trie backing tag search
mpvplayer/ libmpv video player window
tieconfig/ Shared tie client config: Android-safe path resolution (Dir/ResolvePath/Load), the [Collections.*] connection editor (Editor), and per-app collection resolution (AppCollection)
tiethumb/ Shared filehost-backed gallery.Thumbnailer (tie relation (hash, "thumbnail", thumbHash) + dimensions), used by tie-view and tie-fm's preview grid; the tie client and filehost resolve per call so collection/config switches need no rewiring
third_party/fyne/ Vendored Fyne fork — git submodule tracking the imgview branch of github.com/uidbz/fyne (replace directive in go.mod)

The tie module is a pinned dependency (github.com/uidbz/tie) fetched from the Go module proxy; no local checkout is needed. (It was previously referenced via a replace directive to a sibling ../tie checkout — removed when v0.4.2 was tagged — and was imported as git.sr.ht/~uid/tie before the sourcehut→GitHub migration.) The pwplay client (github.com/uidbz/pwplay/client, stdlib-only HTTP client for the pwplay-server REST API that tie-audio's remote backend drives) is a pinned dependency the same way; its old vendored copy under cmd/tie-audio/internal/pwplay/ was deleted. tie-audio's local playback backend uses the same module's player package (the engine pwplay-server runs) — currently resolved via a replace directive to the sibling ../pwplay checkout. The pwplay server itself stays in its own repo — tie-audio's test-env builds it from the sibling ../pwplay checkout.

Note: the gallery library serves imgview, tie-view and tie-fm's preview grid; tie-audio has its own UI code and shares only the tagselection widget (as does tie-fm) and the tie client dependency.

The Fyne fork submodule must be checked out before building: git clone --recurse-submodules … or git submodule update --init. The fork adds: canvas.GLVideo (libmpv video embedding), Android system-bar toggle via SetFullScreen, platform-dependent texture-cache lifetimes (see below), a Wayland resize repaint fix (the first buffer swapped after a compositor-imposed resize — e.g. a sway tile — is still allocated at the old size by Mesa, so the draw loop schedules one extra repaint via repaintAfterResize; without it a startup single image stays at the requested geometry until the next external repaint, e.g. pointer focus), and a canvas.Image.Resize that only repaints (no texture invalidation) for ImageScaleFastest/ImageScalePixels — the texture holds the source pixels unchanged in those modes, so per-frame resizes (pinch zoom in ImageView.TouchMoved) draw the cached texture instead of re-uploading the bitmap. Pair this with *image.RGBA bitmaps (toRGBA): any other pixel type costs a full-bitmap draw.Draw on the UI thread at upload.

The Makefile's install targets build with -tags "wayland egl gles gles2" when $WAYLAND_DISPLAY is set and $DISPLAY is not (a "pure Wayland" session without XWayland — Fyne targets X11 by default, so the Wayland driver and GLES/EGL need explicit tags there). Passing a bare XDG_SESSION_TYPE=wayland is not enough: XWayland may still serve X11, in which case the X11 driver stays correct.


Build

git submodule update --init   # first time only: fetch the Fyne fork
go build ./cmd/imgview        # local viewer
go build ./cmd/tie-view        # tie-backed viewer
go build ./cmd/tie-fm          # twin-panel file manager
go build ./cmd/tie-audio  # tag-driven audio player
go build -tags nompv ./cmd/imgview ./cmd/tie-view ./cmd/tie-fm   # without libmpv (no video)
go test ./...

All binaries require CGo (Fyne depends on OpenGL / system graphics). Video playback and video thumbnails require libmpv (-lmpv); the nompv build tag selects a stub implementation in mpvplayer/mpv_stub.go. Android builds are now libmpv-backed too (arm64-v8a) — see docs/ANDROID.md and third_party/android-libs/ for the vendored cross-compiled libraries and build-android.sh / bundle-native-libs.sh for the APK wiring; mpv.go compiles under !nompv (not !android), with EGL vs GLFW glue split into mpvplayer/platform_android.go / platform_desktop.go. The migrated_fynedo build tag is implicit in the vendored Fyne fork.

The Android scripts (build-android.sh, install-android.sh, build-install-android.sh) cover imgview, tie-view, and tie-audio. The audio player bundles no native libs (local playback outputs through OpenSL ES, an Android system library), so the scripts skip the libmpv vendored-libs check and the bundling step for it (needs_mpv gate in build-android.sh) and always build it with -tags nompv: tie-audio imports gallery (album grid) → mpvplayer, and the stub is what keeps a solo build-android.sh tie-audio (the per-app path build-install-android.sh uses) from failing on mpv/client.h — and keeps the APK from linking a libmpv it never ships.

The APK packager must be the fork's cmd/fyne. build-android.sh builds third_party/fyne/cmd/fyne into .build/fyne (git-ignored; FYNE_CMD= overrides) and never uses a fyne from $PATH. fyne package compiles no Java: it writes the precompiled classes.dex embedded in cmd/fyne/internal/mobile/dex.go of the tool you run. The fork's GoNativeActivity.java adds setSystemBarsVisible(boolean), which Window.SetFullScreen reaches via canvas_android.goapp.SetSystemBarsVisible → JNI; an APK packaged with the upstream fyne.io/tools CLI lacks that method in its dex, android.c logs Fyne: cannot find method setSystemBarsVisible (Z)V at startup, and every SetFullScreen is a silent no-op (the bars stay visible while the gallery believes it is fullscreen — the cause of the earlier failed attempts). The Java hides bars in immersive-sticky mode (WindowInsetsController on API 30+, SYSTEM_UI_FLAG_* below) and re-applies the hidden state in onWindowFocusChanged. After editing the Java, regenerate the dex with cd third_party/fyne/cmd/fyne/internal/mobile && go generate (needs javac, ANDROID_HOME with an API 30+ platform and build-tools ≥ 34; the gendex picks the highest-numbered ones) and commit dex.go with it. See docs/ANDROID.md.


Memory Optimization

The gallery implements several optimizations for fluid performance on mobile and lower-end devices:

LRU Tile Cache (gallery/tilecache.go)

In-memory thumbnail cache with size limits (1500 desktop, 300 mobile — ~3 pages each, so page-back navigation is served from memory; one page exactly filled the old 500/150 budget, so any round-trip evicted the whole previous page). Uses insertion-order LRU eviction. Cache operations are mutex-protected.

Off-screen culling (Fyne clip)

TileLayout.Layout positions every tile on the page each pass; it does not Hide/Show tiles or track a visible-index set. Off-screen tiles cost nothing to render because Fyne's GL painter culls any object outside the scroll container's clip rect (Paint() early-returns in third_party/fyne/internal/painter/gl/painter.go; the scroll registers as a clip via IsClip in internal/driver/util.go).

Do not add a viewer.scroll.OnScrolledgallery.Refresh() handler: a *fyne.Container.Refresh() refreshes every child (re-uploading every tile texture) and, fired per scroll frame, made scrolling choppy. This was the "virtual scrolling" regression removed after commit 6a83e2f.

Do not call grid.Refresh() or scroll.Refresh() on hot paths either — both recursively refresh children and force re-upload of every visible tile texture. Use layout.relayoutGrid() (direct Layout + canvas.Refresh(grid)) to reposition tiles, and scroll.ScrollToOffset(...) for programmatic scrolling (fast path, also used by the J/K scroll hotkeys).

Texture cache lifetime (fork patch)

Off-screen tile textures expire after a cache lifetime and are re-uploaded when scrolled back into view. The vendored fork makes the default platform-dependent (third_party/fyne/internal/cache/lifetime_desktop.go = 10 min, lifetime_mobile.go = 1 min for mobile/wasm); the FYNE_CACHE env var still overrides. Trade-off: more VRAM on desktop (~500 MB worst case for a fully scrolled 500-tile page) in exchange for hitch-free scroll-back.

Pagination Button

Large "Load Next Page ▼" button appended to grid.Objects after all tiles when currentPage < maxPages - 1. Handled specially in Layout(): given full width and fixed height (60px desktop, 80px mobile).

Pagination links & scroll behavior (gallery/pagination.go)

The bottom bar shows 2-6 numbered page links depending on the grid width (paginationSlotCount), with first/last pages pinned, the current page kept visible, and hidden middle ranges collapsed into "…" labels (pageSlots). Fyne has no window-resize callback, so a transparent sizeWatcher widget stacked below the scroll container (it never intercepts pointer events) reports grid-width changes and rebuilds the links on window resize.

  • Page navigation scrolls to the top: LoadGallery always resets the scroll offset to 0 (new page, new directory, new tag query).
  • Returning from the single-image view scrolls to the opened tile: ChangeImage records viewer.openedInfo; showGallery switches back to that entry's page (it may differ from the page the user left, after next/prev navigation) and scrolls the tile into view. When the page must be re-placed, the tile's page-relative index is handed to layout.pendingReveal, which PlaceTiles consumes after the new page is laid out; on an unchanged page the scroll is computed directly from the live tile position.

Mobile Config Adjustments

Config.AdjustForMobile() reduces memory footprint on mobile platforms:

  • TileWidth: 300 → 200
  • ImagesPerPage: 500 → 100
  • Workers: 8 → 4
  • TileGap: 5 → 3

Applied automatically in both main programs after LoadConfig if platform.IsMobile() is true.

Image Cleanup

showGallery() releases full-size images (fyneImage.Image = nil) when returning to the gallery view to free memory immediately. LoadImageToCache detects a released cached view on its next hit and reloads it (loadOrPlaceholder); setImage updates the existing canvas.Image in place because the widget renderer caches the object at creation — replacing it would leave the released blank image on screen.


Gallery layout (gallery/tilelayout.go)

Justified row layout

TileLayout.Layout implements a justified row layout: tiles are grouped into rows and scaled so each row fills the full container width with no horizontal gaps. All tiles in a row share the same height, determined by the sum of their aspect ratios.

Algorithm (O(n)):

currentY = 0
for each row:
    accumulate tiles until (containerWidth - gaps) / sumAspects <= targetH
    rowH = (containerWidth - gaps) / sumAspects
    if last row: cap rowH at targetH
    for each tile in row:
        tileW = (tile.width / tile.height) * rowH
        place at (x, currentY), size (tileW, rowH + extraH)
        x += tileW + gap
    currentY += rowH + extraH + gap

Key config: TileWidth (default 300 px) is the target row height. Larger values → fewer, taller rows. TileGap (default 5 px) is the inter-tile gap. extraH = labelHeight (22 px) when filename labels are visible.

tile.width / tile.height are the thumbnail's pixel dimensions, which preserve the original image's aspect ratio (thumbnails are width-scaled with imaging.Resize(w, 0, Lanczos)). When ImageInfo.Width / .Height are pre-populated (from tie metadata), the placeholder tile uses those instead, giving the correct aspect ratio from the first layout pass.

Placeholder tiles and lazy loading

PlaceTiles decodes loading.png once and shares it across placeholder tiles for every slot on the current page, installs the placeholder tiles into layout.tiles/grid.Objects via fyne.Do, and only then sends each ImageInfo to the imagesToLoad channel. The install-before-enqueue order is load-bearing: a worker result can only exist after an enqueue, so the tileUpdater's flush fyne.Do is always queued after the install and its staleness guard matches the new placeholders. The old order (enqueue first) let fast cache-hit flushes run against the previous page's tile list and drop the new page's results as "stale", leaving permanent placeholder tiles. Overlapping placements are serialized by placeMu so the last placement wins deterministically.

Workers (default 8 desktop, 4 mobile) goroutines drain the channel, call GetThumbnailNewImageTile, and forward the real tile to a single tileUpdater goroutine via the results channel. A failed load (network error, truncated blob, decode failure) is re-queued with a per-item budget (maxTileLoadAttempts = 3, tracked in ImageInfo.loadAttempts, reset by PlaceTiles) so transient failures don't strand placeholders; after the budget the placeholder stays. The updater batches write-backs: one fyne.Do per flush (every ~120 ms trailing or 32 tiles) swaps tiles into layout.tiles/grid.Objects and calls relayoutGrid() — replacing the old every-20-images grid.Refresh() storm that re-uploaded all textures. Stale cross-page results are dropped via an Info-pointer guard. Decoded thumbnails are converted to *image.RGBA on the worker so texture upload skips the painter's CPU pixel conversion.

When the current page is not the last page, PlaceTiles adds a large "Load Next Page ▼" button as the final object in grid.Objects. This button is 60px tall on desktop (80px on mobile) and styled with high importance for easy tapping.

tileCache *tileCache holds an LRU in-memory tile cache keyed by path/hash. Cache hits skip thumbnail decoding entirely. Cache size is limited to 1500 tiles on desktop, 300 on mobile.

Thumbnail pipeline

Local (imgview): hash file content (HighwayHash) → check ~/.cache/imgview/<xx>/<yy>/<zz>/<hash> → on miss: decode + EXIF-rotate (JPEG only) → ScaleImage(decoded, tileWidth*2) (imaging Linear filter, not Lanczos) → JPEG quality 90 → write cache.

Remote (tie-view): tiethumb.Thumbnailer.GetThumbnail (shared package, used by tie-fm's preview grid too; consulted only for reader-backed entries — local files keep the local pipeline even when a Thumbnailer is set) →

  1. Check the reader's cached thumbHash (pre-populated from query expand, or a tc.Get fallback) → GET filehost/<thumbHash>
  2. On miss: download full blob → decode → scale → encode → PUT filehost/upload/<thumbHash>Set(imageHash, "thumbnail", thumbHash)Set(imageHash, "dimensions", "WxH")

Thumbnail width is always TileWidth * 2 (2× for HiDPI). Height is aspect-preserved. Directory/archive tiles show the preview image at ImageInfo.previewIndex with a semi-transparent folder icon overlaid (dirPreviewThumbnail), disk-cached under <hash>d so the icon never leaks into the same image's plain thumbnail.


Image dimensions in tie metadata

When tie-view generates a thumbnail it writes:

  • (imageHash, "thumbnail", thumbHash) — content address of the thumbnail blob
  • (imageHash, "dimensions", "WxH") — original image pixel dimensions, e.g. "3840x2160"

Both relations are fetched in the initial Query(Expand: true) call at zero extra cost. tieReader.dimensions holds the raw string; tieReader.Dimensions() parses it and implements gallery.DimensionProvider.

gallery.ReadCustom checks each CustomReader for DimensionProvider and pre-populates ImageInfo.Width / ImageInfo.Height. NewImageTile uses these when non-zero so placeholder tiles already have correct aspect ratios, preventing layout reflow as thumbnails load.


Key types

gallery.TileLayout (gallery/tilelayout.go)

  • Implements fyne.Layout
  • tiles []*Tile — current page, indexed 0…pageSize-1
  • offset int — index of first tile on this page within viewer.imageFiles
  • minHeight float32 — total pixel height of all rows; reported by MinSize
  • tileCache *tileCache — session-scoped LRU in-memory thumbnail cache
  • imagesToLoad chan *ImageInfo — work queue for loader goroutines
  • results chan loadedTile — finished tiles from workers; the tileUpdater goroutine batches them into one relayout per flush (~120 ms trailing / 32 tiles)

gallery.Tile (gallery/tilelayout.go)

  • width, height float32 — thumbnail pixel dims (= original aspect ratio)
  • landscape boolwidth > height (kept for informational use; layout uses aspect ratio directly)
  • Content *canvas.Image — the actual rendered image (ScaleMode = Fastest, FillMode = Contain)
  • Info *ImageInfo — metadata and reader

gallery.ImageInfo (gallery/imageinfo.go)

  • Width, Height int — pre-stored original dimensions (0 = unknown, use thumbnail dims)
  • CustomReader CustomReader — tie or archive reader
  • OnOpen func() — if non-nil, replaces default image display (used for directories)
  • ThumbnailIsScaled bool — set when thumbnail is already at tileWidth*2
  • PreviewPaths / PreviewReaders / previewIndex — collection & video swipe previews (see "Directory/archive/video tiles: preview swipe")

gallery.Gallery (gallery/gallery.go)

  • Renamed from Viewer in Phase 1 refactoring
  • imageFiles []*ImageInfo — full list for the current gallery source
  • currentPath string — absolute path of the currently open directory
  • isFullscreen bool — tracks fullscreen state; toggled by ToggleFullscreen()
  • Thumbnailer Thumbnailer — if non-nil, used instead of the local disk cache for reader-backed items (CustomReader != nil); plain local files always use the local disk cache
  • OnImageChange func(*ImageInfo) — called after ChangeImage
  • Platform() *Platform — accessor for mobile vs desktop behavior (Phase 5)

Optional CustomReader interfaces (gallery/gallery.go)

Interface Method Purpose
Openable Open() Directory entries: replaces tap with navigation
VideoFile IsVideo() bool Marks entry as video (shows placeholder, caller opens player)
VideoStreamer StreamURL() string Returns direct HTTP URL so libmpv streams without downloading
DimensionProvider Dimensions() (w, h int) Pre-known image dimensions for stable placeholder layout
DisplayNamer DisplayName() string Human-readable name for thumbnail label (fallback: filepath.Base(Path()))
PreviewProvider Previews() ([]CustomReader, error) Browsable collection (tie dir/archive): supplies preview readers for the tile thumbnail + swipe cycling; called lazily on loader goroutines
CoverProvider CoverThumbnail() + StoreCoverThumbnail([]byte) Server-cached cover for a collection tile (tie archive): serves previewIndex 0 without enumerating the collection; the gallery stores a generated first preview as the cover on a miss

Navigation and fullscreen

Android back button

Handled in Gallery.KeyPress (window-level SetOnTypedKey handler). On mobile, Hotkey{"Back", showGallery} is registered during InitHotkeys. TypedKey on ImageView is intentionally not wired on mobile (suppresses soft keyboard).

Fullscreen on mobile

ChangeImage automatically calls window.SetFullScreen(true) on mobile when opening a single image. showGallery calls window.SetFullScreen(false) when returning to the gallery grid. Both are guarded by viewer.isFullscreen to avoid redundant calls.

Directory navigation (imgview)

ShowImageDir(path) wipes imageFiles, resets pagination, calls ReadImageDir, LoadGallery, then window.SetContent. No history stack exists. The only back mechanism is PathLevelUp (hotkey), which calls ShowImageDir(filepath.Dir(currentPath)). The Android back button returns to the gallery view but does not pop a directory stack.

Directory/archive/video tiles: preview swipe

Directory and archive entries carry ImageInfo.PreviewPaths (all images in the folder / all image members in the archive); tie directories and archive blobs instead resolve ImageInfo.PreviewReaders lazily via the PreviewProvider interface (resolved inside dirPreviewThumbnail on a loader goroutine — except that a CoverProvider entry's initial view (previewIndex 0) prefers its server-cached cover, skipping enumeration entirely). Video tiles offer up to videoPreviewFrames (10) frame thumbnails spread across the duration by seek percent (mpvplayer.ExtractFramePercent), cached per frame index (videoThumbnailCachePath -N suffix; frame 0 keeps the historic suffix-free name). The tile displays the preview at ImageInfo.previewIndex (folder tiles get a folder-icon badge, video tiles a play icon). A transparent dirSwipeOverlay widget covers only tiles whose ImageInfo.HasPreviews() is true (regular image tiles have no overlay, so the scroller keeps native drag/fling there): taps forward to the tile, horizontal drags call Tile.cyclePreview(±1) (swipe left = next, right = previous, wraps around), vertical drags forward to scroll.ScrollToOffset so page scrolling still works when a gesture starts on an overlaid tile. cyclePreview regenerates the thumbnail off-thread and swaps Content.Image in place, invalidating only that tile's texture.

Gallery hotkeys (default bindings)

  • N: Toggle filename labels on/off (ToggleFilenames) — desktop only
  • J / Down: Scroll down
  • K / Up: Scroll up
  • Backspace: Navigate to parent directory (PathLevelUp)
  • Q / Escape: Quit (gallery view) or return to gallery (image view)

All hotkeys are configurable in config.toml under [Gallery] section.

Routing: gallery-grid hotkeys (scroll, PathLevelUp, ToggleFilenames) are registered in TileLayout.InitHotkeysGallery.KeyPress (window-level SetOnTypedKey) dispatches layout.hotkeys unconditionally, which is required because no widget is focused in the gallery grid on desktop. viewer.hotkeys (image-view actions) reach the desktop via the focused ImageView.TypedKey and mobile via window-level dispatch (ShouldHandleHotkeysAtWindowLevel). Registering grid hotkeys on viewer.hotkeys leaves them dead on the desktop gallery view.

Gallery UI controls

  • ☰ Menu button (bottom-right of the gallery grid; a second floating ☰ instance overlays the bottom-right of the single-image view): Opens popup menu with options
    • Show/Hide filenames
    • Image info (only while a single image is displayed): toggles the metadata overlay — filename, path, type, dimensions, byte size, format, and a curated EXIF section (camera, exposure, dates). Also bound to the I key ([Image] ToggleInfo, image-view hotkey). Tapping the dimmed scrim outside the panel closes it; the overlay follows next/prev navigation and closes when returning to the grid.
    • (Future: more options)
  • ◀/▶ Toggle (bottom-left, tie-view only): Show/hide tag sidebar
  • Pagination (bottom-center): 2-6 elided page links by width (see "Pagination links & scroll behavior") and the "Load Next Page" button at end of gallery

Default: Filename labels are OFF by default (saves ~22px vertical space per row). Use ☰ menu → "Show filenames" to enable.


tie-view navigation model

Directory entries are tieDirReader instances implementing Openable. Tapping one calls browseDir(uid)fsTree.showDirUID(uid, "") — the virtual filesystem sidebar tree navigates, not the gallery grid. Both tieDirReader and tieArchiveReader also implement PreviewProvider, so their tiles show a content thumbnail (first image inside, folder-badged) and support swipe cycling; the static folderIcon in cmd/tie-view/folder.go remains only as the fallback for empty or unreadable collections.

tieArchiveReader additionally implements CoverProvider: the tile's initial view uses the server-cached cover (tie relation (archiveHash, "thumbnail", thumbHash), riding along with the query's expanded attributes at zero extra cost) instead of downloading the whole archive blob. On a cover miss the first member is extracted once (one full download) and uploaded as the cover via StoreCoverThumbnail, so the slow path runs once ever per archive and is shared by every machine. Full-archive downloads (cover misses and swipe cycling) are capped at 2 concurrent via archiveFetchSem in cmd/tie-view/tie.go — each blob is held in memory for swipe cycling.

Tag-based navigation uses readFromTie(viewer, tc, include, exclude, "tag", browseDir). The query uses Expand: true so thumbnail hashes and image dimensions arrive inline with no extra round-trips.

tie: URL argument (cmd/tie-view/tieurl.go): the first positional argument may be a tie: URL — tie:<hash> (also tie://<hash> or a bare 64-hex hash) for a single subject, or tie:/virtual/path. It replaces the default startup view: an image opens full-size (one-item gallery), a video plays, a directory (content hash or DirUID — indistinguishable by shape, so tc.Get + tie-type classify) is browsed via fsTree.showListing, an archive opens on its members. A tie:/path file leaf resolves through tc.StatPath and opens its parent directory's gallery focused on the file (StatInfo.ParentUIDs[0]ReadTieDirshowListing(dir, hash)), so the directory stays arrow-key navigable; a leaf with no parent listing falls back to the single-image view. A hash with no triples (ErrNotFound, e.g. a never-imported blob) is still attempted as a plain image. tie-fm's "tie URL" file associations hand the path form to tie-view (Command = "tie-view %f", TieURL = true).

Local path argument (cmd/tie-view/local.go): when the first positional argument is neither a tie: URL nor a bare hash and exists on the local filesystem, tie-view opens it the way imgview does (classifyLocalInput): a directory becomes the gallery (ReadImageDir — subdirectories and archives browsable, videos playable), an image opens full-size inside its parent directory's gallery, an archive opens on its image members; anything else shows an "unsupported file type" dialog. The tile-tap handler dispatches like imgview's (ShowArchiveShowImageArchive, InputIsDirShowImageDir, video → openLocalVideo playing in place when CustomReader == nil, else openTieVideo), so local navigation (tiles, PathLevelUp) works without a tie server. Local images are thumbnailed through the local disk cache even though tie-view sets a Thumbnailer — the gallery only consults it for reader-backed entries (layout.thumbnailer != nil && info.CustomReader != nil in TileLayout.GetThumbnail) — and the tag panel/quick tag bar stay inert (the tagger is keyed by content hash; toggleTagger returns early on a local image).


tie-fm preview mode (cmd/tie-fm/internal/ui/preview.go)

Each pane's toolbar has a picture-icon button (appended by FileManager.InitPreview, wired in main.go) that swaps the table for an embedded gallery.Gallery grid of the pane's listing: folders (badged, swipe-cyclable content previews; tapping navigates), images, and videos (frame thumbnails; tapping plays in-pane via libmpv — fs.Streamer URL for tie, materialized temp copy elsewhere). The toggle is runtime-only; both panes start in table mode. When the tapped image/video's file type has a configured association (Config.AppFor), the tap instead opens the entry externally through FileManager.openEntrytie:<hash> for associations with AppAssoc.TieURL, a stream URL for Stream, a materialized copy otherwise — so e.g. tie-view becomes the image opener end to end.

  • The gallery assumes window ownership (ChangeImage/showGallery/ ChangePage call window.SetContent/SetTitle/SetFullScreen), so the pane is wrapped in paneWindow/paneCanvas facades: content swaps are redirected to the pane's center slot (fm.content.Objects[0]), canvas Size reports the pane size, popups/overlays/focus pass through, and title/fullscreen requests are dropped.
  • entryReader adapts fs.Entry to gallery.CustomReader (Path() = tie content hash when known, so the local disk cache and tie relations are shared with tie-view/imgview). tieFileReader adds tiethumb.ThumbReader
    • DimensionProvider (server-cached thumbnails/dimensions; Dimensions never fetches synchronously — ReadCustom type-asserts on the UI goroutine). dirReader adds Openable (navigate) + PreviewProvider (lazy List on a loader goroutine). Image/video detection is by extension (isImageName): gallery.IsImageFromPath sniffs content, which tie/mtp URIs cannot do.
  • The grid is re-fed (ReadCustom + ChangeGallery) on every reload and re-sort; ShowGrid first drops any open image/video. Bindings that would fight tie-fm (Quit, ShowGallery, FullScreen, PathLevelUp, SaveImage, RunCmdA) are cleared from the shared gallery.Config; window-level keys route to the active pane via PreviewHandlesKey (Escape: image/video → grid → preview off). Gallery.ShowGrid and Gallery.VideoActive were added to the gallery API for this embedding.
  • Both panes share one tiethumb.Thumbnailer (main.go); it resolves the tie client and filehost per call, so tie config/collection switches (which replace the registry's TieFS) need no rewiring. TieFS.Client() exposes the underlying client.
  • tie-fm now imports gallerympvplayer: the default build needs libmpv; -tags nompv builds with placeholder video tiles and no in-pane playback.

tie-fm dir-types (cmd/tie-fm/internal/fs/, ui/filemanager.go)

tie dir-types (audio-dir, image-dir, video-dir, document-dir, or custom free-form labels — extra (uid,"tie-type",<label>) triples on a directory, managed client-side by GetDirType/SetDirType(s)) classify a virtual directory as a media collection (tie-view galleries, tie-audio albums). tie-fm stamps and edits them:

  • Copy/Move into tie as ▸ (submenus next to the plain "Copy/Move into tie") transfers with a chosen label — the four built-ins (fs.BuiltinDirTypes) plus a "Custom…" prompt. Operations.CopyAs/MoveAs set Op.DirType; after a successful import the engine type-asserts the destination backend to fs.DirLabeler and stamps an fs.DirLabel (dir-type
    • display name + tag-date) on the freshly created directory root (directory transfers) or the destination directory itself (file transfers, name skipped). TieFS.LabelDir creates the path when absent, so an empty source tree still gets its label; stamping is additive (existing labels preserved), and a backend without DirLabeler fails the op rather than silently dropping the label (a name/tag-date-only label is best-effort and skipped instead, so plain copies to non-tie backends keep working).
  • Import metadata parity with tie import: every file imported into tie (any copy, not just album imports) goes through TieFS.importFile, which follows WriteFileWithProgress with writeAudioMetadataclient.ExtractMediaMetadata + a batch writing the same title/artist/album-artist/album/year/track/duration triples client.ImportFile writes (non-audio files yield no metadata and no write). Directory transfers additionally record the new root's folder name (filename/name) and tag-date via the DirLabel, so media apps (tie-audio) can title the directory and sort it by import time without listing it.
  • Directory type… (tie directory context menu) shows the current labels and edits them as a checkbox set (built-ins + current customs) plus a comma-separated custom field, applied via fs.DirTyper.SetDirTypes (diff-replacing; the structural directory marker is preserved client-side). The Properties dialog shows a "Dir type" row (StatInfo.DirTypes, filled from client.GetDirTypeStat's own TieType collapses multi-valued tie-types and never surfaces labels).
  • Import as albums… (local directory context menu, ui/albumimport.go; full user docs: docs/TIE-FM-ALBUM-IMPORT.md) bulk-imports a local library as albums: a form picks the dir-type (built-ins + custom, default audio-dir — it selects the label stamped on each album root), a destination template, and optional comma-separated tags, then client.PlanAlbumImport runs off the UI goroutine with a ScanProgress dialog (network-mounted libraries take minutes to probe), and a plan dialog lists one checkbox row per album (title, rendered destination, tracks, size, warnings; dest-less groups are fixed unchecked). The template field pre-fills from the tie config's ImportDest[dir-type] (falling back to /{albumartist}/{year} - {album}, refilled on type change unless hand-edited), is validated on Scan via client.ValidateDestTemplate, and overrides the config lookup as AlbumPlanOptions.Template (the same semantics as the CLI's --dest; an empty field keeps source-path placement). The form's "Remember as default for this type" checkbox writes the template back into the tie config's [ImportDest] (client.SaveConfig at Config.Path(), or a fresh user-config config.toml when tie-fm runs on its embedded default), so the CLI renders the same layout. Placement below the album root is disc-aware (tie ≥ v0.5.3): multi-disc albums route each disc'd member to cd<N>/… and single-disc albums drop disc-like directory levels, keyed off DISCNUMBER tags with a fallback to disc-like directory names (CD1, Disc 2); the planner records per-file destinations in AlbumGroup.SubPaths, and a disc-structured whole-tree group converts to an explicit Files+Sidecars+SubPaths import (cover art rides along). Confirming enqueues one op per selected group via Operations.ImportAlbum (from a goroutine — the queue is small and feeding it blocks), each album shown as its own progress row; failures are per-group (one bad album doesn't abort the batch) and summarized when the batch finishes. The ops engine's album path (fs/ops.go) imports at g.Dest verbatim (Op.ExactDest — not the usual B.Path/<name>): whole-tree groups mirror their SourceDir there, file-list groups (Op.Files = g.Filesg.Sidecars) place each file per Op.FileSubPaths (copied from g.SubPaths) or, when absent, into Dest/<rel-below-SourceDir> via the Importer interface with TotalSize preset from the plan, and archive groups are plain single-file imports with no dir-type stamp (the blob carries the audio-archive classification itself). The form's tags are applied to every imported file (Op.Tags, via the fs.TagImporter interface — a backend without it fails the op) and to the album root (Op.DirTags), so the albums appear on tie-audio's tag-driven cover wall; the group's aggregated artist/album/year ride the DirLabel onto the album root (Op.AlbumArtist/AlbumTitle/AlbumYear). Archive groups tag only the blob — the destination directory stays unlabeled, untagged and without aggregates.

The tie-view sidebar is an AppTabs with Tags (below), Files (tree.go: the tie virtual filesystem tree — directories as branches, image files as leaves; selecting a directory shows its images, hidden dirs toggled via the ☰ menu) and Settings (see above). On a phone-width window it is a slide-over drawer instead of an HSplit pane (viewer.SidebarDrawer, decided once at startup from the canvas width — tie-view has no shell that re-composes on resize); picking a tag or a directory closes it, and the selection is summarised by the gallery's filter chip row. See "Sidebar drawer" under "tie-audio compact layout".

Initial load: tc.Get("tags") returns two relations:

  • "all" — every tag ever applied in the collection
  • "favorite" — curated quick-pick tags (falls back to "all" if empty)

On selection change: CoTagsForQueryExcludingInput(include, exclude, "") is called in a goroutine. The result replaces both the search trie and the quick-pick list. Clearing the selection restores the original full lists. SetFavorites([]string) is used (not ClearFavorites + AddFavorite loop) to avoid a double-refresh bug: ClearFavorites triggers Refresh with 0 items, then adding items without another Refresh leaves the list visually blank.

Profile switch: reloadTags() (returned from makeTagSidebar) clears selected tags, clears the trie, clears favorites, then re-runs the tc.Get("tags") fetch. Called as the onApply callback from makeSettingsTab.

makeTagSidebar also receives a *imageTagger. Whenever the tag list is (re-)fetched, tagger.SetAllTags(allTags) is called so the image tagger's search trie stays in sync with the sidebar's list without a second network request. The reverse direction is wired too: the tagger's OnTagsAdded callback (fired after successful tie writes) lets the sidebar grow its trie and full-list snapshot without a selection-clearing reload.

Starring from the sidebar: the sidebar sets ts.ShowStars = true, so its quick-pick rows and search results carry the same ☆/★ button as the image tagger. ts.OnStar persists via tc.RegisterFavorite/UnregisterFavorite (optimistic, rolled back on error) and setStarred updates the local starred list, the ☆/★ state (ts.ToggleStar), the default quick-pick view (applyFavoritesView: favorites, or every tag while none are starred — only re-shown when no selection narrows the list) and the tagger (tagger.SetFavoriteTags). The tagger's OnStarChanged feeds the same setStarred without a second write, so both star sets stay identical.


Settings tab (cmd/tie-view/settings.go)

makeSettingsTab(tc, onApply, quickEditor) returns a "Settings" tab holding an inner AppTabs:

  • Connection — the shared tieconfig.Editor (tieconfig/editor.go), which edits the tie client config's [Collections.*] entries as plain TOML at tieConfigPath (resolved once in main via tieconfig.ResolvePath; on Android this is under $FILESDIR, so it survives reinstalls). Picking a collection in the dropdown fires onSelect (immediate switch, no file write); Apply writes the file and fires onApply. Both rebind the live client via the struct-overwrite pattern (*tc = *client.NewTieClientFor(...)), which propagates the rebuilt inner client (private fields baked at construction) to every existing *TieClient pointer without tie module changes. tie-view remembers its collection selection in Preferences (tie.collection); see "Per-app tie collection (profile)" below.
  • Quick tagsmakeQuickTagEditor (see "Quick tagging mode").
  • StartupmakeStartupTab chooses what the desktop gallery shows at launch (Preferences startup.page/startup.tag, written on change): favorites (the default — images tagged favorite, the historical -tag flag default), latest images (latestFromTie: a reverse tie-type query for every image-file, sorted client-side by tag-date, so untagged imports appear), a chosen tag, or a blank gallery. An explicit -tag flag overrides the configured page for that launch; a tie: URL argument and the mobile DCIM view take precedence over the setting.

Per-app tie collection (profile)

The tie config file is shared by all GUI apps and the tie CLI, so its DefaultCollection cannot be per-app — apps that bound it directly kept clobbering each other's selection. Instead each app stores its own selection and has its own default profile name, and tieconfig.AppCollection(cfg, stored, appDefault) resolves what to bind at startup: the stored selection (when it still names an entry) → the app's default entry (when present) → the file's DefaultCollection.

App Selection stored in Default profile
tie-view Fyne Preferences tie.collection images
tie-audio AppConfig.TieCollection audio
tie-fm Config.TieCollection (Menu → "Select tie collection…") files

In the shared connection editor, picking a collection in the dropdown switches the picking app immediately (no tie-file write); only Apply writes the tie file (setting its DefaultCollection, which the tie CLI then follows).


tie-audio playback backends (cmd/tie-audio/internal/playback/)

tie-audio plays through a PlaybackBackend interface (16 methods: queue ops, transport, seek/volume, Status() polling at 500 ms). Two backends:

  • pwplay remote (pwplay.go) — drives a pwplay-server over HTTP.
  • local (local.go) — pwplay's own player engine (github.com/uidbz/pwplay/player, the same code pwplay-server runs: gapless queue, boundary-accurate position, volume 0–2, pure-Go FLAC/MP3/WAV/OGG/Opus decoders) running in-process, outputting through the engine's platform sink: PipeWire on Linux, OpenSL ES on Android (player/sink_opensl.go in the pwplay repo; the sink abstraction is PlayerOptions.Sink, nil = platform default). Queue entries stay Session.StreamURL(hash) strings — the engine downloads each track to $TMPDIR on load and sniffs the format from the filehost's Content-Type — so the UI's URL-keyed metadata registry, resolver and playlist saving are unchanged. Queue mutations settle synchronously (bounded in-process polls of the engine's playlist length) so the UI's optimistic-update contract holds; PlayAlbum mirrors the remote's append-trim-settle-play dance (never a transient empty queue — the engine's select drains its command channels in random order). localBackend.Close() (io.Closer) shuts the engine down; player.SetBackend swaps backends live on settings Save.

Selection: AppConfig.Backend = pwplay | local (Settings → "playback" select); empty = platform default — local on Android, pwplay elsewhere (config.DefaultBackend, FILESDIR probe). Session.BackendErr records a failed local construction (falls back to remote; surfaced as a dialog at startup and on settings save).

Desktop build note: the local backend links libpipewire-0.3 (build-time headers via pkg-config; runtime .so), so desktop tie-audio now needs PipeWire dev packages installed to build — same toolchain as pwplay itself.

Android media session (internal/ui/mediabridge.go): a permanent transportView feeding the fork's MediaSessionDriver API (fyne.io/fyne/v2/driver/mobile, Android-only; the type assertion fails elsewhere). It pushes metadata/state/artwork to the foreground-service notification + lock screen and owns the audio-focus policy (transient loss → pause + resume on gain, duck → volume ×0.35 + restore, permanent loss → pause, becoming-noisy → pause). Enabled only while the local backend is active. The Java side (PlaybackService in the fork's dex), the JNI glue and tie-audio's custom AndroidManifest.xml are documented in docs/ANDROID.md ("tie-audio local playback").

Engine limitations inherited from pwplay-server: the sink format is fixed by the first track loaded (a later different-rate track plays at the wrong speed), and formats are FLAC/MP3/WAV/OGG/Opus only (no AAC/M4A/ALAC).


tie-audio sidebar & settings (cmd/tie-audio/internal/ui/)

The browse sidebar is an AppTabs (bar at the bottom) with Tags / Files / Settings tabs, mirroring tie-view's sidebar.

  • Tags mirrors tie-view's tag-sidebar semantics: ShowStars = true, the quick-pick list shows the starred ("tags","favorite") tags (falling back to every tag while none are starred, labeled "All tags" / "Favorites"), ☆/★ toggles persist via RegisterFavorite/UnregisterFavorite (optimistic, rolled back on error), and co-tag refinement narrows the list on selection. Both tag relations arrive in one tc.Get("tags") fetch (Session.TagSets). The whole TagSelection sits in a container.NewVScroll so a large tag count doesn't inflate the window's minimum size.
  • Files (fstree.go) is the tie virtual filesystem tree, an audio variant of tie-view's tree.go: directories are branches, audio files (media-type audio/* or tie-type audio-file) are leaves. Selecting a directory replaces the cover wall with its subdirectories as album tiles (titles from the subdir's own album/name triple, else the folder name) followed by its standalone tracks as single-track albums; selecting a file opens it as a single-track album. Successful listings are cached per session and failures too (a dead server would otherwise be re-queried per tree layout pass) — but an unmapped path (no DirUID yet) is deliberately not cached, so a directory imported there later appears on the next read. The ☰ menu's "Reload albums" (browsePage.reloadWall) re-runs whatever the wall currently shows — a directory listing is re-read from the server (fsTree.reload, dropping the tree's cached listings), the latest-albums page re-queries, a tag wall re-runs the selection's query — and drops the decoded-cover cache, so albums (or artwork) imported while tie-audio runs (e.g. via tie-fm) appear without an app restart. The same reload is bound to the compact nav bar's Refresh button and to pull-to-refresh on the grid (gallery.OnPullRefresh); while an album track list is open it re-fetches that album in place instead. A collection switch likewise resets the tree cache (fsTree.reset). Hidden directories (leading .) are toggled via the gallery ☰ menu (matching tie-view).
  • Settings is built by the App shell (buildSettingsTab) and appended to the same AppTabs; the shell reuses the tab item's content to open the settings view full-screen on mobile, and the page's Back button re-selects its own tab instead of leaving the settings view. The page is a nested border (app form on top, connection editor as the center) — a VBox would collapse the scroll-wrapped editor to its small scroll minimum, and border sections size to their own MinSize even on narrow mobile windows. tie-audio's own config.Save writes back to the path Load resolved ($FILESDIR on Android), never re-deriving it via os.UserConfigDir (which fails on Android — no $HOME/$XDG_CONFIG_HOME — surfacing an "xdg" error on every settings save).

The settings page shares tieconfig.Editor with tie-view and applies a collection switch the same way: picking a collection in the dropdown calls Session.SetCollection (and Apply calls Session.SetTieConfig), which overwrite *session.Tie in place with NewTieClientFor(...) (the struct-overwrite pattern), so every existing *TieClient holder — browse page, queue page — sees the new collection. The selection persists as AppConfig.TieCollection (default profile audio; see "Per-app tie collection (profile)" above). The sidebar then reloads its tags (selection cleared) and clearAlbums empties the wall in the background, so stale albums from the prior collection can neither display nor be opened; the user stays on the settings page (no ChangeGallery).

Startup page: the app form's "startup page"/"startup tag" items (AppConfig.StartupPage/StartupTag, settings.go) choose what the cover wall shows at launch and after a collection switch (browsePage.applyStartupPage): blank (default), latest albums (Session.LatestAlbums — every audio-dir and audio-archive merged, sorted by tag-date, so untagged tie-fm imports appear), favorites (the favorite tag), playlists (the reserved playlist tag), or a chosen tag. Tag-based pages select their tag in the sidebar via SetSelected (no OnSelectedChanged, and no co-tag refinement — the full tag list stays until the user changes the selection).

Compact vs regular layout: see "tie-audio compact layout" below — on a phone-width window the sidebar becomes a slide-over drawer, the queue an album-grouped list, and the transport a mini bar plus a full-screen Now Playing page. App.showBrowseView is the shared back target; shellWindow.SetBottom swaps the pinned bottom bar without touching the window content.


tie-audio browse wall table view (cmd/tie-audio/internal/ui/walltable.go)

The browse wall can render its albums two ways, and the user chooses between them: the cover grid (default) or a sortable table listing the same albums. The toggle is the gallery ☰ menu's "Table view" item (cover mode) and the table's own "Cover view" button in its top button row; the choice persists as AppConfig.BrowseView (covers | table, preserved across settings saves like the column sets).

  • One listing, two renderings. Every wall feed (tag query, latest, directory listing, clear) records its []data.Album on the browse page via setWallAlbums (feed goroutines hand off with fyne.Do), then calls showWall, which routes to viewer.ChangeGallery() (covers) or showWallTable() (table). showBrowse is view-aware the same way, so Back from an open album returns to the active rendering; a layout-mode switch keeps it too (showCurrentViewshowBrowse).
  • The table (wallTable, mirroring trackTable on the shared tablewidget): columns cover/title/artist/year/kind (allWallColumns; compact layout uses compactWallColumns = cover, title, artist with no Columns dialog, like the album view). The Columns dialog (regular layout) persists to AppConfig.WallColumns. Header-click sorting re-sorts the table's own album slice via the FlexTable OnSort hook and is re-applied when a fresh listing arrives, so a reload keeps the order. A row tap opens the album (openAlbum); a secondary tap opens the same Play/Add-to-playlist popup as a cover tile (showAlbumMenu, which now takes any fyne.CanvasObject for positioning).
  • Row/cell consistency: the Art column's cell text is the album UID and cells render via FlexTable.CellText(col, row) (the cover cell reads the UID back from it), so the TableWidget's in-page filter, its pagination (1000/page) and sorting can never desync a displayed cell from its album. Activation maps display rows back with albumAt (table.Offset + row, already filter-translated by the TableWidget).
  • View composition (wallTableRoot): in the regular layout the sidebar is re-parented into the table's own HSplit (the gallery's tree is off-screen; only one tree is ever attached to the canvas). In the compact layout the table gets a locally built gallery.FilterChipRow above it and the gallery's sidebar drawer is stacked over it via the new Gallery.DrawerObject() accessor, so OpenSidebar/CloseSidebar/ SidebarOpen (and the Back-key unwind) work unchanged over the table. gallery.FilterChipRow is the exported renderer SetFilterChips now shares. The table view has no cover→queue drag and no swipe gestures (the compact nav bar covers navigation).
  • Tests: walltable_test.go (columns, cell values, sort, offset mapping, re-sort on re-feed) and walltable_smoke_test.go (live integration against the tie test-env — skips when it is down, self-seeds two fixture albums tagged smoketest on first run — driving the full App through feed, sort, open, back and both toggle directions).

tie-audio compact layout (cmd/tie-audio/internal/ui/app.go, layout.go)

The shell renders one of two layouts, chosen from the window width, not from IsMobile() — Fyne reports tablets as mobile, and a tablet (or a phone in landscape) has room for the split layout:

regular (desktop, tablet in landscape) compact (phone, tablet in portrait)
Tags/Files sidebar HSplit pane inside the gallery slide-over drawer over the grid + filter chip row
Playlist trackTable in a permanent right-hand HSplit pane, grouped by album headers full-screen album-grouped list (queueList)
Album track list persisted column set + Columns dialog fixed compactAlbumColumns (track no / title / duration)
Transport regularBar (one row, both sliders) miniBar (full-size controls; + volume row on the playlist view) → full-screen nowPlayingPage
Bottom nav Tags / Playlist / Settings / Refresh under the mini bar
  • gallery.Platform.CompactLayout(width) is the width heuristic (width <= 0 counts as compact on mobile: the canvas has not been laid out yet, and guessing the split layout for a phone shows it for one frame). CompactWidth is 1000 dp — the bar is a tablet in landscape, not a phone: a 10" tablet in portrait is ~800dp, which fits a split on paper but leaves a 160dp sidebar, narrower than a whole phone screen.
  • The heuristic is only the default: AppConfig.Layout (auto/compact/regular, Settings → "layout") pins the mode, because width cannot classify a tablet (the same device is ~800dp portrait and ~1280dp landscape, dp varies by density bucket, and whether a 10" screen should show panes is taste). ui.compactForWidth(pref, platform, width) is the single decision point; App.refreshLayoutInfo shows the mode in use, what auto would pick, and the measured width — otherwise the user has no way to see why the layout looks the way it does. gallery.NewPlatformFor(bool) builds a Platform with a chosen device type (tests, and callers that know).
  • widthWatcher (ui/layout.go, the same Resize-override trick as gallery.sizeWatcher) is stacked into every shellWindow.wrap, so a rotation or window resize reaches App.onWidthApp.setCompact. The rebuild is deferred through fyne.Do because onWidth runs inside a layout pass. Views keep their state; only the containers around them are rebuilt (queuePage.setCompact, browsePage.setCompact, applyBottomBar, showCurrentView). shellWindow.dropSplit remembers the divider offset so a compact excursion doesn't reset it.
  • Back key: App.syncBackHandler installs the window-level SetOnTypedKey handler only while it has work to do: something to unwind (drawer open, or a view other than the cover wall), or the configured desktop hotkeys. With no handler set, Fyne's mobile driver routes Back to GoBack() (leave the app) — capturing it unconditionally would make tie-audio impossible to exit. Gallery hotkeys are deliberately not dispatched (their defaults include Quit). Drawer state changes arrive via gallery.Gallery.OnSidebarToggled, which also fires on a scrim dismiss.
  • Desktop hotkeys (ui/hotkeys.go): the app config's [Hotkeys] table binds playback actions to Fyne key names — PlayPause, Stop, Next, Previous, SeekForward/SeekBackward (±10 s), VolumeUp/VolumeDown (±10 %). Defaults: Space, X, N, P, Right, Left, =, -. The table merges over the defaults per action (config.ResolveHotkeys): an unmentioned action keeps its default, an empty list unbinds it, unknown actions are ignored. Desktop only (initHotkeys returns early on mobile); the bindings drive the shared player, so they fire from every view, and a focused text entry consumes keys first, so Space is safe while typing. App.keyPress handles the Back/Escape unwind first, then dispatches hotkeys.
  • Swipes: left on the wall → playlist, right → open the drawer (compact), pull down at the top of the wall → reload its feed (gallery.OnPullRefresh), swipe up on the mini bar → Now Playing, swipe down over its cover → back, left-edge swipe in the queue → back to the wall (swipe.go).

Transport: one controller, three views (transport.go, transportview.go, nowplaying.go)

player is the controller (poll loop, URL→Track registry, repeat, pending seek/volume guards) and holds no widgets. Each view (regularBar, miniBar, nowPlayingPage) builds its own widgets — Fyne objects cannot have two parents — and receives a transportState per poll via transportView. All views stay registered whether on screen or not, so navigating never loses or double-applies playback state.

The transport buttons (play/pause, prev, next, stop) are the shared transportButton widget (transportbutton.go): a round icon button whose primary style is a filled primary-color disc with a contrasting (ColorNameForegroundOnPrimary) icon — play/pause — and whose flat style is a bare themed icon that gains a translucent disc on hover (desktop) and while pressed. Taps get a short ripple fade (pressFade + a fyne.Animation), mirroring the standard button's tap animation. applyPlayIcon flips the play/pause icon via transportButton.SetIcon.

Sliders are built by player.newSeekSlider / newVolumeSlider, not by the views: the applying / pendSeek / pendVol echo guards have to be shared. apply sets applying once around every view's SetValue, so a poll pushing server state into three views cannot echo back a Seek (each Seek clears pwplay's ring buffer — an echo is audible).

nowPlayingPage exists because a phone-width bar cannot hold a usable seek slider and the metadata: there both sliders span the full window width, with the seek times under the slider rather than beside it. The miniBar is a single row — a 64 px cover plus the same four full-size transport buttons as the Now Playing page (prev / play / next / stop at the nowPlayingButton / nowPlayingPlay sizes) — so playback is fully steerable from the cover wall; the track labels and the two sliders are Now-Playing-only. (The bar once carried the labels, but a Label with TextTruncateEllipsis reports a MinSize of just "…", so the centered pair always rendered as two rows of dots.) The exception is the volume slider: it joins the mini bar (below the controls row, miniBar.setVolumeVisible) while the compact playlist view is on screen — the one compact view with room for it, and the only one besides Now Playing where volume matters.

Album artwork (covers.go)

coverStore caches decoded album art keyed by album UID, shared by the cover wall's coverThumbnailer, the queue's album header rows, the grouped list's headers, the mini bar and the Now Playing page. Covers are downscaled to coverMaxEdge (512) and the cache is bounded (coverLimit = 120, insertion -order eviction) — decoded RGBA is ~1 MB each.

  • Lookup is the non-blocking cache peek; Request resolves off the UI goroutine and calls back on it (synchronously on a hit, so table/list cells paint without flicker); Get blocks and is for the gallery's thumbnail workers. Concurrent asks for one album coalesce (inflight).
  • A coverless album is cached as a nil image (data.ErrNoCover) so it is never re-probed; a fetch failure is not cached, so a cover that was merely unreachable is retried.
  • coverCell (table cell / list row) records the album UID it is showing and drops a late result if the cell has been recycled — the same hazard that forces the queue's play indicator to be a label rather than an icon.
  • Resolution: Session.CoverBytesForUID(uid) → the album's own thumbnail relation, else dirCoverHash (a cover.*/folder.*/front.* child, else the first image), else the first track's embedded picture (trackEmbeddedCover: the first audio file of a directory album in filename order, or the track itself for a standalone track). A collection switch clears the store and re-points its session (settings.go).

data.Track.AlbumUID is what artwork and grouping key off: TrackForHash takes it from the track's tie-parent edges, AlbumTracks overrides it with the directory being listed, and the playlist path excludes the playlist's own UID (trackForHash(hash, excludeParent)) — a saved playlist parents every track it lists, and grouping by it would collapse a mixed playlist into one album with one cover.

Compact playlist (queuelist.go)

buildQueueRows groups the queue into album headers followed by their tracks by consecutive runs of AlbumUID (falling back to the Album tag, then one "Unknown album" run), so the same album at two positions is two groups — what the user sees and reorders. Header rows carry groupStart/groupCount so album-level actions address the block directly. The same row model backs the desktop playlist table (see below).

  • tap a track → playRow (Goto + a Play when the server still isn't playing: pwplay's Goto clears stopped but not paused, so a bare Goto on a paused player loaded the track without resuming); long-press (TappedSecondary on mobile) → Play / Remove track / Move up / Move down; header ⋮ or long-press → Play album / Remove album / Move album up / down (block moves reuse reorderSelection+reorderMoves).
  • Reordering uses a drag handle only: a drag starting anywhere on the row is the same gesture as the list's own scrolling. queueDragHandle converts travelled pixels to queue positions via the fixed track-row height and shows a ghost label; queuePage.setDragging suppresses poll rebuilds mid-gesture.
  • Removal needs playback.PlaybackBackend.Remove(index) (pwplay RemoveTrack); queuePage.removeRange deletes back-to-front because each backend removal reindexes the entries after it.

rebuildTracks feeds both the table and the list regardless of which is on screen, so a layout switch shows a populated view immediately.

Desktop playlist table (albumtable.go, queuetitlecell.go)

The regular layout's trackTable runs in grouped mode: the same buildQueueRows model as the compact list, so consecutive same-album runs get a two-row-tall header row (72 px, via a new FlexTable.SetRowHeight wrapper — widget.Table has no reset-to-template, so 0 recomputes it) with the cover, album name and artist·year·count line. There is no per-track Art column: defaultQueueColumns is both the queue's default and its available set (trackTableOpts.availableCols), so the Columns dialog can't resurrect it and a persisted cover key is dropped on load. Details:

  • The title column's cell is a queueTitleCell — one widget that renders either a track title or the album header, so a recycled cell never needs recreation (the FlexTable only recreates cell content when the position changes, not when the row kind flips under it).
  • Header rows are inert: FlexTable.RowSelectable blocks selection, drags and double-taps on them.
  • Display rows ≠ playlist indices: the play indicator, double-tap play, drag-reorder and external drop gaps all map through the row model (playlistIndexForRow, playlistGapAt).
  • "Play album" / "Add to playlist" update the view optimistically (noteQueueReplaced/noteEnqueued) and queuePage.pending suppresses status polls until the backend call has settled, so a stale mid-mutation poll can't flicker the table back to before the action; pwplayRemote.Enqueue waits for pwplay's asynchronous add to land before returning (mirroring Insert). endQueueMutation then reconciles with one forced status fetch.
  • Two latent tablewidget bugs fixed here: FlexTable.headerBgColor was never set (nil-colored header backgrounds — the GL painter tolerates it, the software painter crashes), and WidgetCell never resized its content on the first layout pass (the vendored container.Clip drops its first layout), so the renderer resizes the content directly.

Sidebar drawer (gallery/drawer.go, gallery/filterchips.go)

Gallery.SidebarDrawer selects the drawer over the HSplit in CreateView; OpenSidebar / CloseSidebar / SidebarOpen / OnSidebarToggled drive it, and ToggleSidebar (the bottom-bar button, a filter icon in drawer mode) flips the overlay instead of rebuilding the content — the grid keeps its scroll position. The drawer is drawerLayout{scrim, panel}: the panel takes 85% of the width capped at 360, the scrim dismisses on tap or a leftward drag, and both a scrim and a panel-level tap sink stop pointer events reaching the tiles underneath. CreateView must drop the drawer when building the split (and vice versa): the sidebar object cannot have two parents.

SetFilterChips / TagFilterChips render the active tag selection as a scrollable chip row above the grid (each chip's ✕ removes its tag via TagSelection.RemoveSelected; the row reopens the drawer). It only makes sense in drawer mode — with the sidebar off-screen the selection would otherwise be invisible — so callers set it only then. tie-view enables the same drawer for phone-width windows (decided once at startup; it has no shell that re-composes on resize).


tie-audio audio-archive playback (cmd/tie-audio/internal/data/archive.go)

An audio-archive album is a single archive blob (a zip of a ripped album), so its tracks have no content address until Session.archiveTracks mints one: the blob is downloaded, each audio member is extracted (archivelib.List/Open, the same library tie-view uses), content-hashed (HighwayHash, putlib.AddressOf), and uploaded to the filehost when missing (HEAD check, then PUT /upload/<hash>). Playback then hands pwplay the ordinary baseURL/memberHash stream URL, which keeps the server — wherever it runs — fetching from the filehost rather than from the client device; the filehost's content-sniffed Content-Type lets pwplay pick a decoder for the extension-less URL, same as any tie blob. The upload happens once ever per member (content-addressed ⇒ idempotent and shared across machines); the resolved track list is cached per session keyed by archiveHash@hostURL, so re-opening the album is free and even an app restart only re-downloads the archive to re-run the existence checks. The extracted member blobs carry no triples — they are invisible to queries, exactly like tiethumb's uploaded thumbnails before their relation is written.

Track metadata (title/artist/album/year/track no/duration) is parsed from the member bytes with tie's metadata/tag fork (tag.ReadFrom on a bytes.Reader — no temp files); untaggable members fall back to the member filename. Tracks sort by member directory, then track number, then filename, so multi-disc zips (one subdirectory per disc) stay grouped. Each track's AlbumUID is the archive hash, so queue grouping and artwork key off the archive. If the archive has no thumbnail relation yet, the best cover (a cover.*/folder.*/front.* image member, else any image member, else the first track's embedded picture) is scaled to 512 px, uploaded, and recorded as (archiveHash, "thumbnail", thumbHash) — best effort (failures logged), and afterwards the cover wall, CoverBytesForUID and even tie-view see the artwork. Note the wall's coverStore may already have cached the album as coverless for the session, so a fresh cover can show as the placeholder until the next wall rebuild.

The Files tab surfaces audio-archives alongside audio files: tree leaves that open as albums, and archive album tiles in directory listings (dir.Archives filtered by TieAudioArchive). ArchiveEntry.TieType is a single value here (an archive carries exactly one *-archive type), unlike the multi-valued tie-type caveat for files.

Testing: archive_test.go fakes a filehost with httptest (GET serves hash-verified blobs, HEAD reports presence, PUT /upload/<hash> stores after a checksum compare — that is the whole upload protocol; putlib.UploadMultipart is a raw PUT despite the name) and covers member resolution, upload dedup, the session cache and the host-keyed cache. archive_integration_test.go skips unless the tie test-env runs and verifies the flow end to end with real tagged FLAC fixtures (testdata/archive-src/): parsed tags, byte-identical member streaming, and the cover thumbnail relation. cover_test.go / cover_integration_test.go cover the album-cover resolution chain, including the embedded-picture fallback (fixture testdata/track-with-cover.flac, a tagged FLAC with an embedded cover). Integration configs must set cfg.DefaultCollection (not just cfg.Collection) — NewTieClient binds the former, and the library default points at the user's real server via their config file.


tagselection.TagSelection API (tagselection/tagselection.go)

Method What
AddTag(tag) Insert into search trie (lowercased; original case in caseMap)
ClearAllTags() Reset trie and caseMap
AddFavorite(tag) Append to quick-pick list (no Refresh — use SetFavorites for updates)
SetFavorites([]string) Replace quick-pick list and call Refresh once
ClearFavorites() Empty quick-pick list and Refresh
ClearSelected() Empty selected-tag list and Refresh
AddSelected(*TagItemData) Move tag to selected set; fires OnSelectedChanged
SetSelected([]string) Replace selected list + Refresh, WITHOUT firing OnSelectedChanged (for externally loaded state, e.g. tags fetched from tie)
RemoveSelected(tag) bool Drop one selected tag (included or excluded) and fire OnSelectedChanged, as if the user had tapped it. For out-of-widget controls over the same selection (the gallery's filter chip row). SetSelected is not a substitute: it rebuilds every entry as included and fires nothing
SelectedTags() ([]string, []string) Returns (included, excluded) tag slices
SetListLabel(string) Change the bold label above the quick-pick list
SetFavoriteMaxRows(n) Cap visible rows in the quick-pick list (0 = uncapped)
SetSelectedMaxRows(n) Cap visible rows in the selected-tag list (0 = uncapped)
SetStarred([]string) Replace the starred-tag set and refresh the quick-pick list
OnSelectedChanged func() Callback fired on any selection change
OnNewTag func(tag string) Called when user presses Enter with typed text but no row highlighted; nil in sidebar, set by image tagger
OnStar func(tag string, starred bool) Called when user clicks ☆/★ on a quick-pick item; set by the tie-view/tie-audio sidebars and the image tagger
ShowStars bool When true, quick-pick items show a ☆/★ toggle button; must be set before first render; used by the tie-view/tie-audio sidebars and the image tagger
KeepSearchFocus bool When true, the search entry keeps keyboard focus after a dropdown selection or Escape (image tagger: lets the user type the next query). Sidebar leaves it false so focus is released and window-level gallery hotkeys keep working; the sidebar additionally calls window.Canvas().Unfocus() in OnSelectedChanged because Fyne List/Check widgets grab focus on tap

Critical: ClearFavorites() calls Refresh. If you then call AddFavorite in a loop without a final Refresh, the list stays visually blank. Always use SetFavorites when replacing the list contents.


Image tagger (cmd/tie-view/imagetagger.go)

imageTagger is a floating panel that overlays the single-image view and lets the user add and remove tags for the displayed image.

Interaction: A single tap on the image calls viewer.OnTapped, which calls tagger.Toggle(hash). The panel slides in from the bottom; a second tap closes it. Navigating to a different image while the panel is open resets it to the new image's tags automatically.

Panel contents: a standard TagSelection widget reused with different semantics from the sidebar:

  • The selected list = tags currently applied to the image in tie. Clicking a tag removes it.
  • The favorites quick-pick list = all known tags, each with a ☆/★ toggle button. Starred tags (those in the tie ("tags","favorite") relation) show ★; clicking toggles the star and persists the change to tie. Clicking the tag itself (not the star) adds it to the image.
  • The search box + dropdown = full-text search across all known tags. Search-result items also show ☆/★ buttons. Clicking a result (not the star) adds it to the image. Typing a name that does not exist and pressing Enter creates the tag via OnNewTag (see below).
  • The include/exclude checkbox has no meaning in this context (it is present because the widget is shared); toggling it fires OnSelectedChanged but the diff against appliedTags will be empty, so no tie write occurs.

Persistence: syncTags(newTags) diffs newTags against the appliedTags snapshot and calls tc.Add / tc.Delete in a goroutine. Newly added tags are also registered via tc.Add("tags","all",tag) so they appear in the sidebar and future tagger sessions, and are fired to the sidebar via OnTagsAdded (wired in makeTagSidebar) so its search trie and full-list snapshot update without a selection-clearing reload.

State tracking: it.hash is the currently VIEWED image (updated by SetCurrentHash whether the panel is open or not); it.panelHash is the image whose tags are LOADED in the panel. ShowForImage/SetCurrentHash reload the panel whenever panelHash differs from the image being shown — using a single field for both made the panel keep the previous image's tags after navigation. loadCurrentTags and the reconcile path populate the widget with SetSelected (never AddSelected loops: those fire OnSelectedChanged per tag and each partial state diffs into spurious tie delete/add writes).

Layout wiring: viewer.OnImageChange appends a persistent taggerOverlay = container.NewBorder(nil, tagger.Panel, nil, nil) to viewer.Content.Objects each time a single image is displayed. tagger.Panel starts hidden; showing/hiding it via Show()/Hide() controls visibility without rebuilding the content stack.

viewer.Content (Stack)
  ├── viewer.CurrentImage      ← ImageLayout container with ImageView
  └── taggerOverlay            ← Border container (transparent when panel hidden)
        └── tagger.Panel       ← Stack(bg rect, padded VBox(header, TagSelection))

Tag list sync: makeTagSidebar calls tagger.SetAllTags(allTags) after every tc.Get("tags") fetch (startup and profile switch), keeping the tagger's search trie up to date without a separate network request.

Key methods:

Method What
newImageTagger(window, tc) Create; Panel is hidden; call SetAllTags to populate trie
SetAllTags([]string) Replace search trie + favorites list
SetFavoriteTags([]string) Replace the starred-tag set and refresh the ☆/★ buttons
Toggle(hash) Open panel for hash, or close if already open for that hash
ShowForImage(hash) Open panel; fetches current tags from tie if panelHash changed
HidePanel() Hide panel without clearing state; fires OnHide
SetCurrentHash(hash) Track current image hash; if panel is open, switches it
OnHide func() Called after the panel hides; used to restore keyboard focus on desktop
OnTagsAdded func([]string) Called on the UI goroutine with tags successfully written to tie; the sidebar uses it to grow its search trie
OnTagsChanged func(hash, tags) Called on the UI goroutine with the panel image's full tag list after a user edit (and after a failure reconcile); wired to quickTagBar.SetTags
OnRatingChanged func(hash, rating) Called on the UI goroutine when the user rates the panel image; wired to quickTagBar.SetRating
OnStarChanged func(tag, starred) Called on the UI goroutine after a ☆/★ toggle here (and with the reverted state on write failure); the sidebar's setStarred consumes it
SetTags(hash, tags) External update (from the quick tag bar) of the panel's applied list via SetSelected — no tie write; ignored unless panelHash == hash
SetRating(hash, rating) External update of the panel's star rating — no tie write; ignored unless panelHash == hash

Quick tagging mode (cmd/tie-view/quicktag.go, quicktagconfig.go, quicktag_editor.go)

A mode for tagging many images fast: the picture stays full-size and two translucent pills (quickTagBar) overlay its edges. The tags bar (Position, bottom by default) holds one icon button per configured tag; the rating bar holds the 1–5 star starRating (shared with the tagger panel, rating.go) plus every tag flagged RatingBar = true (the favorite heart by default) and sits on the opposite edge unless Rating says otherwise. Tapping a button (or pressing its key) toggles the tag on the displayed image and writes to tie immediately (optimistic flip, revert + "failed: tag" flash on error); the stars write (hash, "rating", n) the same way (rate: delete old, add new). A status line beside the tags bar names the hovered control on desktop and confirms changes ("+ favorite" / "− favorite" / "rating 3").

Layout (Rebuild): the quickTagBar widget itself is the column at the tags' edge — a plain VBox of status line and tags pill (image → edge order, reversed for Position = "top"), and Overlay is a Border layout anchoring it to that edge and the rating pill to the other. When Rating names the tags' edge, the rating pill joins the column as the row nearer the image; off drops the stars (flagged tags still form the pill). There is exactly one starRating (b.stars, nil when off) and one rendering of each button; b.cells keeps config order across both pills. The earlier design (Rating = "inline": stars inside the tags pill, with a barLayout that swapped a merged row for stacked pills at layout time via hidden duplicate renderings and a fyne.Do(Overlay.Refresh) from inside Layout) drew the tag buttons over the stars on narrow screens and is gone. RatingKeys optionally binds one key per star (the current rating's key clears it).

Size (Size in the set): one of five presets xs/s/m/l/xl (quickTagSizes), scaling the platform base icon size (40 desktop / 56 mobile, quickTagIconSize) by quickTagSizeScale (0.7 … 1.4); stars are 0.75× the icon and the pill padding is 0.12× (min theme padding). A numeric IconSize > 0 (hand edit) overrides the preset; the editor clears it when a Size is picked.

Toggling the mode: [Image] ShowTagbar key (T, previously an unbound config slot) or ☰ menu → "Quick tagging mode". The on/off state persists in Fyne Preferences (quicktag.enabled). OnImageChange appends quickBar.Overlay to viewer.Content.Objects (below taggerOverlay, so an open tag panel covers the bar) and calls quickBar.SetImage(curReader); toggling while an image is shown adds/removes the overlay in place (syncQuickOverlay, using Gallery.ImageViewActive()).

Speed: tieReader.tags/rating/tagsKnown cache the image's tags and rating from the query's expanded attributes (buildReaders reads RowValues(row, "tag") and rowRating), so the bar paints correctly the instant an image opens; a background tc.Get(hash) then reconciles (directory listings carry no tags). Changes made while that fetch is in flight are kept via pending/ratingPending and a gen counter drops stale results. The bar mirrors its state back into the reader (syncReader) and to the image tagger (OnTagsChangedSetTags, OnRatingChangedSetRating in both directions, no ping-pong since the Set* methods never write). Adds also register the tag in ("tags","all") once per session.

Hotkeys: Gallery.RegisterHotkey(name, fn) (new, gallery/gallery.go) appends to viewer.hotkeys so bindings reach the desktop via the focused ImageView.TypedKey and mobile via window-level KeyPress, like the configured [Image] keys. Call it after viewer.Init() (which resets the list). Bindings can't be removed, so main.go registers each key name once and looks up the live action in quickKeys at press time; per-tag Key defaults to the button's position 1–9 (quickTagConfig.normalized). Bar keys only fire while the mode is on and the image view is active.

Hit-testing: only the cells (Tappable+Hoverable) and the pill (tapSink, swallows near-miss taps) are hit-testable; the rest of the bar strip is transparent to taps, and nothing is Draggable, so swipes and the floating ☰ button keep working.

Config (quicktagconfig.go): <config dir>/tieview/quicktags.toml ($FILESDIR on Android, else os.UserConfigDir()); a commented default (favorite with heart.png/heart-grey.png) is written on first run. The top level is the default set (QuickTagSet, embedded in quickTagConfig — exported name because go-toml's marshaler skips unexported embedded fields); [Collections.<name>] tables are per-collection overrides keyed by the tie config's collection name.

Position = "bottom"   # tags bar edge: "bottom" or "top"
Size = "m"            # "xs" / "s" / "m" / "l" / "xl"
Rating = "auto"       # rating bar edge: "auto" (opposite the tags), "top", "bottom", "off" (no stars)
RatingKeys = ["F1", "F2", "F3", "F4", "F5"]   # optional, one per star

[[Tag]]
Tag = "favorite"
On  = "heart.png"      # applied
Off = "heart-grey.png" # not applied; empty = grayscale On icon (grayscaleResource); both empty = text button
Key = "1"              # optional Fyne key name
RatingBar = true       # show next to the stars instead of in the tags bar

[Collections.photos]   # this collection gets its own bar
Position = "top"       # optional; falls back to the top-level value
[[Collections.photos.Tag]]
Tag = "print"
On  = "icons/printer.png"

quickTagConfig.For(collection) resolves the set to show: an override's Tag list replaces the default list entirely (even when empty), while its Position/Size/IconSize/Rating/RatingKeys fall back to the top-level values when unset; "" or an unknown collection yields the default. loadQuickTagConfig runs migrateLegacy: a file whose default set has Rating = "inline" or neither Rating nor Size predates the two-bar layout, so every set gets inlineauto and, if it flags no tag, its favorite entry gets RatingBar = true (the top level also gets Size = "m" so the file counts as migrated on the next save). The active collection is tieClient.Config.DefaultCollection (the connection editor sets it to the applied entry); applyQuickTagConfig in main.go re-resolves it and is also run from the settings tab's onApply (onCollectionChanged) so the bar follows a connection switch.

Icon paths are absolute or relative to the config dir; heart.png, heart-grey.png, star-filled.png, star-empty.png are embedded built-ins (a same-named file on disk shadows them). Settings → Quick tags (makeQuickTagEditor) edits the same file in-app: a "Bar for" dropdown (Default / each configured collection, plus any override-only names so stale ones can be removed) picks the set being edited; per-tag cards with a PNG file picker, an "In rating bar" check (RatingBar), a Size select (XS–XL) and a rating-bar placement select (Opposite edge / Top / Bottom / No stars), plus (picked files are copied into <config dir>/icons/) reorder, delete, Apply (save + quickBar.Rebuild), "Use default bar" (drops the selected collection's override) and "Reload file" for hand edits. Switching scope stores unsaved edits in memory (storeForm), but a collection without an override only gains one if its form differs from the default, so browsing the dropdown never creates overrides. The editor returns a refresh func that re-reads the collection list and jumps to the active collection after a connection change. Rebuild swaps the buttons and re-anchors Overlay (same container object, new Border layout) so the live view updates without navigation.


tieReader (cmd/tie-view/tie.go)

type tieReader struct {
    seeker     io.ReadSeeker
    data       []byte
    host       client.FileHost
    client     *http.Client
    hash       string       // content address (HighwayHash, 64 hex chars)
    thumbHash  string       // content address of cached thumbnail
    dimensions string       // "WxH" from tie metadata, e.g. "3840x2160"
    isVideo    bool
    tags       []string     // cached tie tags (quick tag bar); tagsKnown marks them loaded
    rating     int          // cached 1-5 rating (0 = unrated), same lifecycle as tags
    tagsKnown  bool
}

Implements: CustomReader, VideoFile, VideoStreamer, DimensionProvider.

Dimensions() parses dimensions via tiethumb.ParseDimensions(s). The reader also implements tiethumb.ThumbReader (ThumbHash/SetThumbCache) so the shared thumbnailer can read and write back its thumbnail/dimensions cache.


Content addressing

Both local thumbnails and tie blobs use the same HighwayHash key (galleryKey in gallery/hash.go = tieKey in tie/client/tie.go). This means a file's content address is identical whether computed locally or by the tie triplestore, and thumbnail caches are portable across machines.

Local thumbnail cache path: ThumbnailDir/<xx>/<yy>/<zz>/<64-char-hash> (3 levels × 2 hex chars, then full hash as filename).


Config (gallery/config.go, gallery/config.toml)

Key Default Effect
TileWidth 300 Target row height in the justified layout; thumbnail width = TileWidth * 2
TileGap 5 Pixel gap between tiles
Workers 8 Concurrent thumbnail loader goroutines
ImagesPerPage 500 Pagination page size
ThumbnailDir ~/.cache/imgview Local thumbnail disk cache root

Config file locations: ~/.config/imgview/config.toml (Linux), %AppData%\imgview\config.toml (Windows).


Threading model

  • All widget mutations must run on the UI goroutine via fyne.Do(func(){...}).
  • TileLayout.Layout and all fyne.WidgetRenderer methods are called on the UI goroutine automatically.
  • imageLoader goroutines write to layout.tiles[idx] and layout.grid.Objects[idx] via fyne.Do; the grid Refresh is also dispatched via fyne.Do.
  • makeTagSidebar closure variables (allTags, allFavorites, allFavoritesLabel) are only read/written inside fyne.Do blocks, so no mutex is needed.
  • Network calls (tc.Get, tc.Query, CoTagsForQueryExcludingInput, getlib.ReadFile) must run in goroutines, never on the UI goroutine.
  • Gallery.cache is guarded by cacheMu: LoadImageToCache runs both on the UI goroutine (ChangeImage) and on background prefetch goroutines.
  • layout.placement (a sync.WaitGroup) tracks a running PlaceTiles so tests can await page placement deterministically (with the test driver's inline fyne.Do, Wait covers the whole placement); production code never blocks on it — blocking the UI goroutine would deadlock, since PlaceTiles enqueues fyne.Do work to it.
  • The Fyne test driver runs fyne.Do inline on the calling goroutine instead of marshalling to a UI thread, so background work that is safe in production races with test-goroutine widget access. Tests therefore swap in synchronous seams (Gallery.infoMetadataFn for the info overlay's EXIF load) and wait on placement + currentlyLoading + the tile updater's trailing debounce before asserting.

Shared app helpers (gallery/apphelper.go, Phase 4)

Phase 4 refactoring factored common bootstrap code from both mains into shared helpers:

  • NewApp(appID, windowTitle, iconData) — creates Fyne app with ID, icon, and window. Eliminates copy-pasted 3-line bootstrap.
  • ConfigFlag(helpText) — defines -config/-c flags and returns pointer. Caller controls when flag.Parse() is called.
  • NormalizeConfigPath(path) — appends .toml suffix if needed.
  • FocusImageViewOnDesktop(window, viewer) — focuses image view on desktop, skips on mobile (soft keyboard avoidance). Used in OnImageChange handlers.

Video is played in the main window via Gallery.ShowVideo(player, displayName, onClose) (gallery/gallery.go), mirroring ChangeImage's in-window content swap — it does not spawn a separate window. The mpvplayer.Video widget carries a fullscreen button (OnFullscreen callback → Gallery.toggleVideoFullscreen) and toggles its controls bar on tap while fullscreen (Video.Tapped / SetFullscreen). Mobile auto-enters fullscreen on open. Gallery.showGallery (also the Q/Escape/Back handler) closes the player, runs onClose (temp-file cleanup), and restores the grid. Desktop Escape/Q/fullscreen/Space keys are routed through Gallery.KeyPress while a video is showing.

Platform abstraction (gallery/platform.go, Phase 5)

Phase 5 refactoring centralized ~10 scattered IsMobile() checks into a single platform abstraction. The Platform struct wraps device detection and provides semantic methods for platform-specific behavior:

Focus & keyboard routing:

  • ShouldFocusImageView() — focus image view for keyboard nav (desktop only)
  • ShouldHandleHotkeysAtWindowLevel() — route keys to window handler (mobile)
  • ShouldRegisterBackButton() — register Android/iOS Back key (mobile)

Fullscreen management:

  • ShouldAutoFullscreen() — auto-fullscreen on image open (mobile)
  • ShouldExitFullscreenOnGalleryView() — exit fullscreen on gallery view (mobile)

GPU & gesture optimization:

  • ShouldDownscaleImages() — downscale for GPU memory (mobile)
  • UsesMobileDragGestures() — pinch-zoom, momentum scroll (mobile)
  • ShouldUseTapForAction() — prefer tap over swipe (desktop)

Responsive layout:

  • CompactLayout(width) — true when a mobile window is narrower than CompactWidth (1000): panels become full-screen views or slide-over drawers instead of split panes. Width-based, not IsMobile()-based, because Fyne reports tablets as mobile; the threshold sits at a tablet in landscape, so a tablet in portrait is compact. A non-positive width (canvas not laid out yet) counts as compact on mobile. Used by tie-audio's shell (re-evaluated on resize, overridable via AppConfig.Layout) and tie-view's sidebar (decided once at startup). NewPlatformFor(isMobile) builds one explicitly.

Platform detection happens once at Gallery creation via NewPlatform(). All platform-specific logic routes through viewer.Platform() accessor. This is a runtime seam (unified codebase, no build tags), not compile-time.

Extension API (gallery/extension.go, Phase 6)

Phase 6 added comprehensive documentation of the stable extension contract between the gallery library and applications. See gallery/extension.go for full documentation of:

  • Core interface: CustomReader
  • Optional behaviors: Openable, VideoFile, VideoStreamer, DimensionProvider
  • Thumbnailing: Thumbnailer
  • Callbacks: OnImageChange, OnTapped, OnSwipeUp, etc.

The Gallery struct is now organized with extension points clearly separated from internal wiring. See struct definition in gallery/gallery.go for the three sections: Extension API (Callbacks), Extension API (Public Fields), and Internal Wiring.


Common patterns

Replacing a tie client's connection without changing the pointer:

// ws.Client bakes URL and TLS at construction; mutating tc.Config.Webservice
// alone has no effect. Overwrite the struct in place instead:
*tc = *client.NewTieClient(tc.Config)
// All existing *TieClient pointers now see the new ws.Client.

Pre-populating ImageInfo dimensions from a CustomReader:

// In ReadCustom — already wired:
if dp, ok := r.(DimensionProvider); ok {
    info.Width, info.Height = dp.Dimensions()
}
// In NewImageTile — already wired:
if context.Width > 0 && context.Height > 0 {
    t.width, t.height = float32(context.Width), float32(context.Height)
}

Updating a fyne List atomically (single Refresh):

// Wrong — list stays blank after ClearFavorites triggers Refresh:
ts.ClearFavorites()
for _, tag := range tags { ts.AddFavorite(tag) }   // no Refresh here!

// Correct — SetFavorites does one Refresh at the end:
ts.SetFavorites(tags)

The same applies to the selected list when loading external state (image tagger): use SetSelected — it fires no OnSelectedChanged, so partial states never diff into spurious tie writes:

ts.SetSelected(tagsFromTie)