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.



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/everywhere/dock/footer.rb', line 129

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

.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.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/everywhere/dock/footer.rb', line 103

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

.paint_key(key, desc, style) ⇒ Object



121
122
123
# File 'lib/everywhere/dock/footer.rb', line 121

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



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

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



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

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

.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