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.
| 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.
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.go →
app.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.
The gallery implements several optimizations for fluid performance on mobile and lower-end devices:
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.
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.OnScrolled → gallery.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).
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.
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).
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:
LoadGalleryalways resets the scroll offset to 0 (new page, new directory, new tag query). - Returning from the single-image view scrolls to the opened tile:
ChangeImagerecordsviewer.openedInfo;showGalleryswitches 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 tolayout.pendingReveal, whichPlaceTilesconsumes after the new page is laid out; on an unchanged page the scroll is computed directly from the live tile position.
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.
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.
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.
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
GetThumbnail → NewImageTile, 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.
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) →
- Check the reader's cached
thumbHash(pre-populated from query expand, or atc.Getfallback) →GET filehost/<thumbHash> - 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.
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.
- Implements
fyne.Layout tiles []*Tile— current page, indexed 0…pageSize-1offset int— index of first tile on this page withinviewer.imageFilesminHeight float32— total pixel height of all rows; reported byMinSizetileCache *tileCache— session-scoped LRU in-memory thumbnail cacheimagesToLoad chan *ImageInfo— work queue for loader goroutinesresults chan loadedTile— finished tiles from workers; thetileUpdatergoroutine batches them into one relayout per flush (~120 ms trailing / 32 tiles)
width, height float32— thumbnail pixel dims (= original aspect ratio)landscape bool—width > 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
Width, Height int— pre-stored original dimensions (0 = unknown, use thumbnail dims)CustomReader CustomReader— tie or archive readerOnOpen func()— if non-nil, replaces default image display (used for directories)ThumbnailIsScaled bool— set when thumbnail is already at tileWidth*2PreviewPaths/PreviewReaders/previewIndex— collection & video swipe previews (see "Directory/archive/video tiles: preview swipe")
- Renamed from
Viewerin Phase 1 refactoring imageFiles []*ImageInfo— full list for the current gallery sourcecurrentPath string— absolute path of the currently open directoryisFullscreen bool— tracks fullscreen state; toggled byToggleFullscreen()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 cacheOnImageChange func(*ImageInfo)— called afterChangeImagePlatform() *Platform— accessor for mobile vs desktop behavior (Phase 5)
| 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 |
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).
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.
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 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.
- 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.InitHotkeys — Gallery.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.
- ☰ 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.
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] → ReadTieDir → showListing(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 (ShowArchive →
ShowImageArchive, InputIsDir → ShowImageDir, 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).
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.openEntry — tie:<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/ChangePagecallwindow.SetContent/SetTitle/SetFullScreen), so the pane is wrapped inpaneWindow/paneCanvasfacades: 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. entryReaderadaptsfs.Entrytogallery.CustomReader(Path()= tie content hash when known, so the local disk cache and tie relations are shared with tie-view/imgview).tieFileReaderaddstiethumb.ThumbReaderDimensionProvider(server-cached thumbnails/dimensions;Dimensionsnever fetches synchronously —ReadCustomtype-asserts on the UI goroutine).dirReaderaddsOpenable(navigate) +PreviewProvider(lazyListon a loader goroutine). Image/video detection is by extension (isImageName):gallery.IsImageFromPathsniffs content, which tie/mtp URIs cannot do.
- The grid is re-fed (
ReadCustom+ChangeGallery) on everyreloadand re-sort;ShowGridfirst drops any open image/video. Bindings that would fight tie-fm (Quit,ShowGallery,FullScreen,PathLevelUp,SaveImage,RunCmdA) are cleared from the sharedgallery.Config; window-level keys route to the active pane viaPreviewHandlesKey(Escape: image/video → grid → preview off).Gallery.ShowGridandGallery.VideoActivewere 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'sTieFS) need no rewiring.TieFS.Client()exposes the underlying client. - tie-fm now imports
gallery→mpvplayer: the default build needs libmpv;-tags nompvbuilds with placeholder video tiles and no in-pane playback.
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/MoveAssetOp.DirType; after a successful import the engine type-asserts the destination backend tofs.DirLabelerand stamps anfs.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.LabelDircreates the path when absent, so an empty source tree still gets its label; stamping is additive (existing labels preserved), and a backend withoutDirLabelerfails 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).
- display name + tag-date) on the freshly created directory root (directory
transfers) or the destination directory itself (file transfers, name
skipped).
- Import metadata parity with
tie import: every file imported into tie (any copy, not just album imports) goes throughTieFS.importFile, which followsWriteFileWithProgresswithwriteAudioMetadata—client.ExtractMediaMetadata+ a batch writing the same title/artist/album-artist/album/year/track/duration triplesclient.ImportFilewrites (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 theDirLabel, 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 structuraldirectorymarker is preserved client-side). The Properties dialog shows a "Dir type" row (StatInfo.DirTypes, filled fromclient.GetDirType—Stat'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, defaultaudio-dir— it selects the label stamped on each album root), a destination template, and optional comma-separated tags, thenclient.PlanAlbumImportruns 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'sImportDest[dir-type](falling back to/{albumartist}/{year} - {album}, refilled on type change unless hand-edited), is validated on Scan viaclient.ValidateDestTemplate, and overrides the config lookup asAlbumPlanOptions.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.SaveConfigatConfig.Path(), or a fresh user-configconfig.tomlwhen 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 tocd<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 inAlbumGroup.SubPaths, and a disc-structured whole-tree group converts to an explicitFiles+Sidecars+SubPathsimport (cover art rides along). Confirming enqueues one op per selected group viaOperations.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 atg.Destverbatim (Op.ExactDest— not the usualB.Path/<name>): whole-tree groups mirror theirSourceDirthere, file-list groups (Op.Files=g.Files∪g.Sidecars) place each file perOp.FileSubPaths(copied fromg.SubPaths) or, when absent, intoDest/<rel-below-SourceDir>via theImporterinterface withTotalSizepreset 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 thefs.TagImporterinterface — 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 theDirLabelonto 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.
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 attieConfigPath(resolved once inmainviatieconfig.ResolvePath; on Android this is under$FILESDIR, so it survives reinstalls). Picking a collection in the dropdown firesonSelect(immediate switch, no file write); Apply writes the file and firesonApply. 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*TieClientpointer without tie module changes. tie-view remembers its collection selection in Preferences (tie.collection); see "Per-app tie collection (profile)" below. - Quick tags —
makeQuickTagEditor(see "Quick tagging mode"). - Startup —
makeStartupTabchooses what the desktop gallery shows at launch (Preferencesstartup.page/startup.tag, written on change): favorites (the default — images taggedfavorite, the historical-tagflag default), latest images (latestFromTie: a reverse tie-type query for everyimage-file, sorted client-side by tag-date, so untagged imports appear), a chosen tag, or a blank gallery. An explicit-tagflag overrides the configured page for that launch; a tie: URL argument and the mobile DCIM view take precedence over the setting.
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 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 ownplayerengine (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.goin the pwplay repo; the sink abstraction isPlayerOptions.Sink, nil = platform default). Queue entries staySession.StreamURL(hash)strings — the engine downloads each track to$TMPDIRon 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;PlayAlbummirrors the remote's append-trim-settle-play dance (never a transient empty queue — the engine'sselectdrains its command channels in random order).localBackend.Close()(io.Closer) shuts the engine down;player.SetBackendswaps 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).
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 viaRegisterFavorite/UnregisterFavorite(optimistic, rolled back on error), and co-tag refinement narrows the list on selection. Both tag relations arrive in onetc.Get("tags")fetch (Session.TagSets). The wholeTagSelectionsits in acontainer.NewVScrollso 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'stree.go: directories are branches, audio files (media-typeaudio/*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 sameAppTabs; 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 ownconfig.Savewrites back to the pathLoadresolved ($FILESDIRon Android), never re-deriving it viaos.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.
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.Albumon the browse page viasetWallAlbums(feed goroutines hand off withfyne.Do), then callsshowWall, which routes toviewer.ChangeGallery()(covers) orshowWallTable()(table).showBrowseis view-aware the same way, so Back from an open album returns to the active rendering; a layout-mode switch keeps it too (showCurrentView→showBrowse). - The table (
wallTable, mirroringtrackTableon the sharedtablewidget): columnscover/title/artist/year/kind(allWallColumns; compact layout usescompactWallColumns= cover, title, artist with no Columns dialog, like the album view). The Columns dialog (regular layout) persists toAppConfig.WallColumns. Header-click sorting re-sorts the table's own album slice via the FlexTableOnSorthook 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 anyfyne.CanvasObjectfor 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 withalbumAt(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 ownHSplit(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 builtgallery.FilterChipRowabove it and the gallery's sidebar drawer is stacked over it via the newGallery.DrawerObject()accessor, soOpenSidebar/CloseSidebar/SidebarOpen(and the Back-key unwind) work unchanged over the table.gallery.FilterChipRowis the exported rendererSetFilterChipsnow 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) andwalltable_smoke_test.go(live integration against the tie test-env — skips when it is down, self-seeds two fixture albums taggedsmoketeston first run — driving the full App through feed, sort, open, back and both toggle directions).
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 <= 0counts 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).CompactWidthis 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.refreshLayoutInfoshows 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 sameResize-override trick asgallery.sizeWatcher) is stacked into everyshellWindow.wrap, so a rotation or window resize reachesApp.onWidth→App.setCompact. The rebuild is deferred throughfyne.DobecauseonWidthruns inside a layout pass. Views keep their state; only the containers around them are rebuilt (queuePage.setCompact,browsePage.setCompact,applyBottomBar,showCurrentView).shellWindow.dropSplitremembers the divider offset so a compact excursion doesn't reset it.- Back key:
App.syncBackHandlerinstalls the window-levelSetOnTypedKeyhandler 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 toGoBack()(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 viagallery.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 (initHotkeysreturns early on mobile); the bindings drive the sharedplayer, so they fire from every view, and a focused text entry consumes keys first, so Space is safe while typing.App.keyPresshandles 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).
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.
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.
Lookupis the non-blocking cache peek;Requestresolves off the UI goroutine and calls back on it (synchronously on a hit, so table/list cells paint without flicker);Getblocks 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 ownthumbnailrelation, elsedirCoverHash(acover.*/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.
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 clearsstoppedbut notpaused, so a bare Goto on a paused player loaded the track without resuming); long-press (TappedSecondaryon mobile) → Play / Remove track / Move up / Move down; header ⋮ or long-press → Play album / Remove album / Move album up / down (block moves reusereorderSelection+reorderMoves). - Reordering uses a
≡drag handle only: a drag starting anywhere on the row is the same gesture as the list's own scrolling.queueDragHandleconverts travelled pixels to queue positions via the fixed track-row height and shows a ghost label;queuePage.setDraggingsuppresses poll rebuilds mid-gesture. - Removal needs
playback.PlaybackBackend.Remove(index)(pwplayRemoveTrack);queuePage.removeRangedeletes 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.
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.RowSelectableblocks 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) andqueuePage.pendingsuppresses 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.Enqueuewaits for pwplay's asynchronous add to land before returning (mirroringInsert).endQueueMutationthen reconciles with one forced status fetch. - Two latent tablewidget bugs fixed here:
FlexTable.headerBgColorwas never set (nil-colored header backgrounds — the GL painter tolerates it, the software painter crashes), andWidgetCellnever resized its content on the first layout pass (the vendoredcontainer.Clipdrops its first layout), so the renderer resizes the content directly.
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).
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.
| 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.
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
OnSelectedChangedbut the diff againstappliedTagswill 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 |
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 (OnTagsChanged ↔ SetTags,
OnRatingChanged ↔ SetRating 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 inline → auto 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.
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.
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).
| 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).
- All widget mutations must run on the UI goroutine via
fyne.Do(func(){...}). TileLayout.Layoutand allfyne.WidgetRenderermethods are called on the UI goroutine automatically.imageLoadergoroutines write tolayout.tiles[idx]andlayout.grid.Objects[idx]viafyne.Do; the gridRefreshis also dispatched viafyne.Do.makeTagSidebarclosure variables (allTags,allFavorites,allFavoritesLabel) are only read/written insidefyne.Doblocks, so no mutex is needed.- Network calls (
tc.Get,tc.Query,CoTagsForQueryExcludingInput,getlib.ReadFile) must run in goroutines, never on the UI goroutine. Gallery.cacheis guarded bycacheMu:LoadImageToCacheruns both on the UI goroutine (ChangeImage) and on background prefetch goroutines.layout.placement(async.WaitGroup) tracks a runningPlaceTilesso tests can await page placement deterministically (with the test driver's inlinefyne.Do,Waitcovers the whole placement); production code never blocks on it — blocking the UI goroutine would deadlock, sincePlaceTilesenqueuesfyne.Dowork to it.- The Fyne test driver runs
fyne.Doinline 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.infoMetadataFnfor the info overlay's EXIF load) and wait onplacement+currentlyLoading+ the tile updater's trailing debounce before asserting.
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/-cflags and returns pointer. Caller controls whenflag.Parse()is called.NormalizeConfigPath(path)— appends.tomlsuffix if needed.FocusImageViewOnDesktop(window, viewer)— focuses image view on desktop, skips on mobile (soft keyboard avoidance). Used inOnImageChangehandlers.
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.
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 thanCompactWidth(1000): panels become full-screen views or slide-over drawers instead of split panes. Width-based, notIsMobile()-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 viaAppConfig.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.
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.
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)