Module: Everywhere::Dock::Footer

Defined in:
lib/everywhere/dock/footer.rb

Overview

Renders the pinned footer. PURE: state in, array of strings out. No IO, no clock, no terminal — every layout decision here is testable with plain string assertions.

Composition rule: build every segment as PLAIN text, measure it, truncate, and only then paint. Measuring or cutting a string that already contains SGR codes is how you end up slicing through "\e[32" and corrupting the terminal, and it makes the width math quietly wrong under NO_COLOR.

Constant Summary collapse

GLYPHS =

Every state gets its own glyph, so the footer stays fully legible with NO_COLOR — color is redundant reinforcement, never the only signal. A nil glyph means "use the spinner frame".

{
  idle: ["", :gray],
  booting: [nil, :cyan],
  building: [nil, :cyan],
  installing: [nil, :cyan],
  running: ["", :green],
  failed: ["", :red],
  stopped: ["!", :yellow]
}.freeze
GAP =
"   "
KEY_GAP =
" · "
ELLIPSIS =
""

Class Method Summary collapse

Class Method Details

.detail_for(target, elapsed) ⇒ Object

Busy targets carry a running clock — the whole reason the dock exists is that a cold Gradle build looks identical to a hung one without it.



75
76
77
78
79
80
81
82
83
# File 'lib/everywhere/dock/footer.rb', line 75

def detail_for(target, elapsed)
  if target.busy?
    seconds = elapsed&.call(target)
    [target.state == :building ? nil : target.state.to_s,
     (UI.elapsed(seconds) if seconds)].compact.join(" ")
  else
    target.detail
  end
end

.fit(pairs, cols) ⇒ Object

pairs: [[plain, painted], …]. Emit painted segments while they fit, measuring only the plain text; cut the first one that doesn't.



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/everywhere/dock/footer.rb', line 172

def fit(pairs, cols)
  out = +""
  width = 0
  pairs.each_with_index do |(text, colored), index|
    gap = index.zero? ? "" : GAP
    if width + gap.length + text.length <= cols
      out << gap << colored
      width += gap.length + text.length
      next
    end

    room = cols - width - gap.length - ELLIPSIS.length
    out << gap << text[0, room] << ELLIPSIS if room.positive?
    break
  end
  out
end

.info(items, cols:, keys: nil, rule: true) ⇒ Object

every preview's footer: labelled facts (the share link, the tunnel URL, the pairing token) instead of target states. items: [[label, value], …].

Packed across as many rows as it takes rather than truncated to one, which is the one place this parts ways with status_row: these values exist to be scanned, read aloud and typed in by hand, and half a URL or a clipped token is worse than useless.



108
109
110
111
112
113
114
115
# File 'lib/everywhere/dock/footer.rb', line 108

def info(items, cols:, keys: nil, rule: true)
  cols = [cols.to_i, 1].max
  lines = []
  lines << UI.gray("" * cols) if rule
  lines.concat(pack(items.map { |label, value| info_pair(label, value) }, cols))
  lines << keys_row(keys, cols) if keys
  lines
end

.info_pair(label, value) ⇒ Object



135
136
137
# File 'lib/everywhere/dock/footer.rb', line 135

def info_pair(label, value)
  ["#{label} #{value}", "#{UI.dim(label)} #{UI.cyan(value)}"]
end

.keys_row(keys, cols) ⇒ Object

Named keys with dot separators, then named keys packed tighter, then bare letters. Measured on the plain text and painted only once a tier fits, so the width math can't be thrown off by SGR codes.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/everywhere/dock/footer.rb', line 146

def keys_row(keys, cols)
  tiers = [
    [:named, KEY_GAP],
    [:named, "  "],
    [:bare,  " "]
  ]
  tiers.each do |style, gap|
    plain = "#{keys.map { |key, desc| plain_key(key, desc, style) }.join(gap)}"
    next unless plain.length <= cols

    painted = keys.map { |key, desc| paint_key(key, desc, style) }.join(gap == KEY_GAP ? UI.gray(gap) : gap)
    return "#{UI.cyan("")} #{painted}"
  end
  ""
end

.pack(pairs, cols) ⇒ Object

Greedy: keep adding facts to the current row while they fit, start a new row when they don't. A single fact wider than the terminal is the only thing that ever gets cut (by fit).



120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/everywhere/dock/footer.rb', line 120

def pack(pairs, cols)
  rows = []
  row = []
  pairs.each do |pair|
    if row.any? && row_width(row + [pair]) > cols
      rows << fit(row, cols)
      row = [pair]
    else
      row << pair
    end
  end
  rows << fit(row, cols) if row.any?
  rows
end

.paint_key(key, desc, style) ⇒ Object



164
165
166
# File 'lib/everywhere/dock/footer.rb', line 164

def paint_key(key, desc, style)
  style == :bare ? UI.bold(key) : "#{UI.bold(key)} #{UI.dim(desc)}"
end

.painted(seg) ⇒ Object



89
90
91
92
93
94
# File 'lib/everywhere/dock/footer.rb', line 89

def painted(seg)
  tint = UI.method(seg[:color])
  parts = [tint.call(seg[:glyph]), seg[:state] == :idle ? UI.gray(seg[:label]) : seg[:label]]
  parts << UI.dim(seg[:detail]) if presence(seg[:detail])
  parts.join(" ")
end

.plain(seg) ⇒ Object



85
86
87
# File 'lib/everywhere/dock/footer.rb', line 85

def plain(seg)
  [seg[:glyph], seg[:label], presence(seg[:detail])].compact.join(" ")
end

.plain_key(key, desc, style) ⇒ Object



162
# File 'lib/everywhere/dock/footer.rb', line 162

def plain_key(key, desc, style) = style == :bare ? key : "#{key} #{desc}"

.plain_width(segments) ⇒ Object



96
# File 'lib/everywhere/dock/footer.rb', line 96

def plain_width(segments) = segments.sum { |s| plain(s).length } + (GAP.length * [segments.size - 1, 0].max)

.presence(str) ⇒ Object



190
# File 'lib/everywhere/dock/footer.rb', line 190

def presence(str) = str.nil? || str.empty? ? nil : str

.render(targets, cols:, keys: nil, frame: 0, elapsed: nil, rule: true) ⇒ Object

targets: ArrayState::Target. elapsed: ->(target) { seconds }. keys: Array<[key, description]>, or nil to omit the keymap row.



37
38
39
40
41
42
43
44
# File 'lib/everywhere/dock/footer.rb', line 37

def render(targets, cols:, keys: nil, frame: 0, elapsed: nil, rule: true)
  cols = [cols.to_i, 1].max
  lines = []
  lines << UI.gray("" * cols) if rule
  lines << status_row(targets, cols, frame, elapsed)
  lines << keys_row(keys, cols) if keys
  lines
end

.row_width(pairs) ⇒ Object



139
# File 'lib/everywhere/dock/footer.rb', line 139

def row_width(pairs) = pairs.sum { |plain, _| plain.length } + (GAP.length * [pairs.size - 1, 0].max)

.segment(target, frame, elapsed) ⇒ Object



63
64
65
66
67
68
69
70
71
# File 'lib/everywhere/dock/footer.rb', line 63

def segment(target, frame, elapsed)
  glyph, color = GLYPHS.fetch(target.state, GLYPHS[:idle])
  { glyph: glyph || UI::FRAMES[frame % UI::FRAMES.size],
    color: color,
    label: target.label,
    detail: detail_for(target, elapsed),
    state: target.state,
    busy: target.busy? }
end

.status_row(targets, cols, frame, elapsed) ⇒ Object

Three levels of degradation, tried in order until one fits: everything; details only on what's actually working; then drop idle targets. A hard truncation is the last resort.



51
52
53
54
55
56
57
58
59
60
61
# File 'lib/everywhere/dock/footer.rb', line 51

def status_row(targets, cols, frame, elapsed)
  segments = targets.map { |t| segment(t, frame, elapsed) }

  candidates = [
    segments,
    segments.map { |s| s[:busy] ? s : s.merge(detail: nil) },
    segments.select { |s| s[:state] != :idle }
  ]
  chosen = candidates.find { |set| plain_width(set) <= cols } || candidates.last
  fit(chosen.map { |s| [plain(s), painted(s)] }, cols)
end