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
-
.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.
-
.fit(pairs, cols) ⇒ Object
pairs: [[plain, painted], …].
-
.keys_row(keys, cols) ⇒ Object
Named keys with dot separators, then named keys packed tighter, then bare letters.
- .paint_key(key, desc, style) ⇒ Object
- .painted(seg) ⇒ Object
- .plain(seg) ⇒ Object
- .plain_key(key, desc, style) ⇒ Object
- .plain_width(segments) ⇒ Object
- .presence(str) ⇒ Object
-
.render(targets, cols:, keys: nil, frame: 0, elapsed: nil, rule: true) ⇒ Object
targets: ArrayState::Target.
- .segment(target, frame, elapsed) ⇒ Object
-
.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.
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 |