class

ArrTop::TUI

Inherits Reference < Object

The full-screen, top-style live view: one line per queue row (download or live import progress bar) under a header, redrawn continuously.

Concurrency (needs -Dpreview_mt) — three fibers, so a slow/hung backend never freezes the UI or the keyboard:

  • Poller fiber (producer): loops @poller.rows@updates, then sleep @refresh. The blocking HTTP call parks this fiber's thread while the UI keeps running on another; a stuck poll can't block quit.
  • Reader fiber: turns raw keypresses into bytes on @keys; sends the nil EOF sentinel when stdin closes.
  • UI fiber (this one, consumer): keeps a cached rows snapshot and selects over @keys, @updates, and a modest animate timeout. It only ever reads cached rows — it never polls — so key-mashing (an arrow key is 3 raw bytes) can't hammer the *arr API.

q/Q/Ctrl-C (or stdin EOF) quits. On quit the poller fiber is stopped (@stop closed) so nothing leaks, and terminal restore is guaranteed by Terminal — the ensure here plus signal traps and an at_exit backstop all funnel to one idempotent restore. No Channel::ClosedError escapes: the poller watches @stop with receive? (nil on close), and @keys/@updates are never closed.

Constants

ANIMATE_INTERVAL = 1.second

How often the UI redraws the cached rows between polls, so live import copy bars (read fresh off disk in build_frame) animate smoothly even while the poller sleeps. This never polls the backends.

CLEAR_EOL = "\e[K"

Clear from the cursor to the end of the line (drops a prior longer line's tail).

CLEAR_EOS = "\e[J"

Clear from the cursor to the end of the screen (drops rows a shrunken frame no longer uses).

HOME = "\e[H"

Cursor-home; each frame starts here so redraws overwrite in place.

Log = ::Log.for("arrtop.tui")

Constructors

new(poller : Poller, refresh : Time::Span, sizes : TorrentSizes = TorrentSizes.disabled, terminal : Terminal = Terminal.new, theme : Theme = Theme.detect(tty: STDOUT.tty?))
Source

Class methods

disk_bytes(row : QueueRow, state : State, progress : ImportProgress | Nil, total : Int64) : Int64 | Nil

The bytes currently on disk behind an importing row's copy — the numerator of the SIZE column's disk/total pair — or nil for every other state (so only rows displayed as Importing show the pair; the rest show just the size). For an importing row it is the resolved copy progress's real bytes, which is 0 at the start of a copy and equal to total for a finished episode (a nil progress0). Pure/unit-testable. row and total are unused for now but kept so callers pass the row's full context.

Source
display_state_and_progress(row : QueueRow, on_disk : ImportProgress | Nil, active : Bool, target : Int64) : Tuple(State, ImportProgress | Nil)

Pure reclassification: given a row, the import progress found on disk for it, whether that file is the folder's active (newest-mtime) copy, and the effective per-episode target, decide the effective display State and the ImportProgress? to render.

Only a Sonarr episode row the *arr reports as Importing is touched (a season pack lists one importing row per episode, all sharing one folder; the pack is copied one file at a time, so at most one episode's file is active):

  • no matching file on disk ⇒ ImportPending (no bar), unless episode_has_file says Sonarr already imported it ⇒ Importing at 100% (a filename-match fallback);
  • a matching file that is NOT the active/newest copy ⇒ a done episode ⇒ Importing at 100% (synthesize bytes == target);
  • a matching file that IS the active/newest copy ⇒ Importing with its real bar (file_bytes / target). Movie rows and non-importing rows keep their real state and import.
Source
download_group_counts(rows : Array(QueueRow)) : Hash(String, Int32)

Counts how many queue rows share each download_id (nil ids ignored). A Sonarr season pack lists one row per episode, all sharing one download, so this count is "episodes in the pack". Pure, so it's unit-testable.

Source
effective_target(row : QueueRow, group_counts : Hash(String, Int32), sizes : TorrentSizes | Nil = nil) : Int64

The effective per-episode import target for row. The Sonarr API reports the whole pack's size on every episode row (no per-episode size), so for an episode row that shares its download_id with others (a season pack of count > 1) the target is estimated as import_target // count. Single-file downloads and movies keep the reported import_target.

When sizes is given and yields an EXACT per-episode size for this row (a configured qBittorrent client has the torrent's file list cached and this episode's file matched), that exact size wins over the pack-average estimate. Otherwise (no client / uncached / no match) the estimate below is used, so behaviour is unchanged when no download client is configured. Pure/unit-testable.

Source
prune_completed?(row : QueueRow, on_disk : ImportProgress | Nil, active : Bool) : Bool

Whether row is a completed season-pack episode that should be dropped from the view, so a pack shows only the actively-copying episode plus the not-yet-started (pending) ones. Pure, so it's unit-tested directly.

Keys off the raw on-disk watch (on_disk/active from watch_progress), not the reclassified display state. Only a Sonarr episode row the *arr still reports as Importing can prune; a row prunes when either:

  • its file is present but is not the folder's active (newest-mtime) copy — its copy finished and Sonarr has moved on to the next episode — or
  • episode_has_file is true (Sonarr already imported it; a filename-match fallback for when the on-disk file can't be located). The actively-copying episode (present and active) is kept even when its on-disk bytes overshoot the per-episode estimate; pending episodes (no file, not yet imported), movies, and every non-importing row are kept.
Source
quit?(byte : UInt8 | Nil) : Bool

Whether byte means "quit": the stdin-EOF sentinel (nil), q, Q, or Ctrl-C (ETX, byte 3 — delivered as input because raw mode disables the SIGINT a cooked terminal would send). Pure, so it's unit-tested directly.

Source
resolve_display(row : QueueRow, group_counts : Hash(String, Int32), sizes : TorrentSizes | Nil = nil) : Tuple(State, ImportProgress | Nil, Bool)

The single source of truth for a row's effective display, shared by the live TUI (build_frame) and the plain-text snapshot (CLI.print_snapshot) so the two never disagree. Given a row and the queue's download_id → count map (from download_group_counts), it chains the per-episode pipeline — effective_target (split a season pack's total across its episodes) → watch_progress (read this episode's file + whether it's the active copy) → display_state_and_progress (reclassify done/active/pending) — and returns {State, ImportProgress?, prune}: the effective state + progress to render, plus whether this is a completed pack episode that should be pruned from the view (see prune_completed?). Both the TUI (build_frame) and the snapshot (CLI.print_snapshot) key off this single prune flag so the two agree on what a season pack shows.

Source
watch_progress(row : QueueRow, target : Int64) : Tuple(ImportProgress | Nil, Bool)

Live import (copy) progress for an Importing row that arrtop can watch on disk, paired with whether the matched file is the folder's active (newest-mtime) copy. Returns {nil, false} for every non-importing row and for an importing row it cannot watch (off-host / file not yet created).

target is the effective per-episode target (see effective_target), used as the bar's denominator. Episode rows use the season/episode-aware watch so each row watches THIS episode's file out of a season pack (and learns whether it is the one being copied); movies use the folder-wide newest file.

Source

Instance methods

build_frame(rows : Array(QueueRow), size : NamedTuple(rows: Int32, cols: Int32)) : String

Builds the full frame string for rows at terminal size: a double-line box (top border with the app title + queue summary + aggregated copy speed, a column-header row, a divider, one data row per queue entry, and a bottom border). The box width tracks cols and the whole frame is capped to the terminal height, degrading by dropping the least-important chrome (divider, then header labels) before it would overflow or wrap. Every line is cleared to end-of-line and the frame ends with clear-to-end-of-screen to erase what a previous, taller frame left behind.

Lines are joined with \r\n (not \n): raw mode disables output post-processing, so a bare \n would drop down without returning to column 0.

Source
quit?(byte : UInt8 | Nil) : Bool

Whether byte means "quit": the stdin-EOF sentinel (nil), q, Q, or Ctrl-C (ETX, byte 3 — delivered as input because raw mode disables the SIGINT a cooked terminal would send). Pure, so it's unit-tested directly.

Source
run

Runs the TUI until the user quits (or stdin hits EOF). Seeds an empty snapshot (drawn immediately — no blocking on the first poll), spawns the producer/reader fibers, then consumes updates and keypresses. The ensure stops the poller and restores the terminal on every exit.

Source