tmux 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tmux
4
+ module Node
5
+ Text = Data.define(:value)
6
+ Strftime = Data.define(:value)
7
+ Dynamic = Data.define(:expression)
8
+ Style = Data.define(:attributes)
9
+ ExpandOption = Data.define(:name)
10
+ Command = Data.define(:command)
11
+ Conditional = Data.define(:condition, :on_true, :on_false)
12
+ end
13
+
14
+ ConditionalStyleState = Data.define(:condition, :on_true, :on_false)
15
+ CapturedFormat = Data.define(:nodes, :style_state)
16
+
17
+ private_constant :ConditionalStyleState, :CapturedFormat
18
+
19
+ # A compiled format string. #nodes is the read-only format AST.
20
+ class Format
21
+ attr_reader :nodes
22
+
23
+ def initialize(nodes) = @nodes = nodes.freeze
24
+ def to_tmux = FormatCompiler.new.compile(nodes)
25
+ alias_method :to_s, :to_tmux
26
+ def ==(other) = other.is_a?(Format) && nodes.eql?(other.nodes)
27
+ alias_method :eql?, :==
28
+ def hash = [Format, nodes].hash
29
+ end
30
+
31
+ # Returned by format and config conditionals so a conditional can
32
+ # be written as `if?(cond) { … }.else { … }` (do/end blocks can't take
33
+ # `else`).
34
+ class Else
35
+ def initialize(builder, index)
36
+ @builder = builder
37
+ @index = index
38
+ end
39
+
40
+ def else(&block)
41
+ raise Error, ".else needs a block" unless block
42
+
43
+ @builder.fill_else(@index, block)
44
+ self
45
+ end
46
+ end
47
+
48
+ # Format branches can also test further conditions before the final else.
49
+ class FormatConditional < Else
50
+ def elsif?(condition, &block)
51
+ raise Error, ".elsif? needs a block" unless block
52
+
53
+ @builder.fill_elsif(@index, condition, block)
54
+ self
55
+ end
56
+ end
57
+
58
+ class ConfigConditional < Else
59
+ def elsif?(condition, background: false, &block)
60
+ raise Error, ".elsif? needs a block" unless block
61
+
62
+ @builder.fill_elsif(@index, condition, block, background:)
63
+ self
64
+ end
65
+ end
66
+
67
+ # Tmux.if? is both a reusable format and a chain that can add branches.
68
+ # Each update replaces the frozen node snapshot without rerunning blocks.
69
+ class ConditionalFormat < Format
70
+ def initialize(builder, conditional)
71
+ @builder = builder
72
+ @conditional = conditional
73
+ super(builder.build.nodes)
74
+ end
75
+
76
+ def elsif?(condition, &block)
77
+ @conditional.elsif?(condition, &block)
78
+ refresh
79
+ end
80
+
81
+ def else(&block)
82
+ @conditional.else(&block)
83
+ refresh
84
+ end
85
+
86
+ private
87
+
88
+ def refresh
89
+ @nodes = @builder.build.nodes
90
+ self
91
+ end
92
+ end
93
+ private_constant :Else, :FormatConditional, :ConfigConditional, :ConditionalFormat
94
+ end
@@ -0,0 +1,586 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tmux
4
+ # The context of a Tmux.format block. Every DSL method returns the builder
5
+ # (conditionals return a handle for `.else`), so calls can be chained.
6
+ class FormatBuilder
7
+ include Evaluation
8
+
9
+ def initialize(style_state: nil, materialize_style_fallback: false)
10
+ @nodes = []
11
+ @style_state = style_state || {fg: nil, bg: nil, attrs: []}
12
+ @materialize_style_fallback = materialize_style_fallback
13
+ @conditional_states = {}
14
+ # After a style block: [the style tmux has, the style to restore]. The
15
+ # restore is emitted lazily (see #settle), so a following #style moves
16
+ # straight to its own style instead of restoring first.
17
+ @owed = nil
18
+ end
19
+
20
+ # Text, with expressions kept dynamic. Interpolate them into a string
21
+ # (`text " #{Tmux.window_index} "`) or pass them as separate parts
22
+ # (`text " ", Tmux.window_index, " "`); each balanced #{...} in a string
23
+ # stays tmux format source. Everything else is literal: the compiler
24
+ # escapes "#" and "%" (and "," and "}" inside branches). "#[" and "#("
25
+ # are rejected, since styles and commands have their own methods; pass
26
+ # `literal: true` to print a string exactly as written.
27
+ def text(*parts, literal: false)
28
+ parts.each do |part|
29
+ next append(part) if part.is_a?(Expression) || part.is_a?(Format)
30
+
31
+ string = part.to_s
32
+ settle
33
+ next @nodes << Node::Text.new(string) if literal
34
+
35
+ if string.match?(/#[\[(]/)
36
+ raise Error, "text #{string.inspect} contains a #[style] or #(command); use style or " \
37
+ "command for those, or pass literal: true to print it as written"
38
+ end
39
+ interpolated_parts(string).each do |kind, chunk|
40
+ @nodes << (kind == :format ? Node::Dynamic.new(Tmux.raw(chunk)) : Node::Text.new(chunk))
41
+ end
42
+ end
43
+ self
44
+ end
45
+
46
+ # A strftime(3) date/time format such as "%a %H:%M", expanded by tmux each
47
+ # time it renders. Unlike #text, its "%" codes are kept; "#", "," and "}"
48
+ # are still escaped.
49
+ def strftime(format)
50
+ settle
51
+ @nodes << Node::Strftime.new(format.to_s)
52
+ self
53
+ end
54
+
55
+ # Appends tmux format source: an Expression, a String or Symbol of format
56
+ # code used verbatim, or a Format whose content is spliced in. Unlike
57
+ # #text, a String here is format code, not literal text.
58
+ def append(content)
59
+ settle
60
+ if content.is_a?(Format)
61
+ @nodes.concat(content.nodes)
62
+ else
63
+ @nodes << Node::Dynamic.new(Tmux.expression(content))
64
+ end
65
+ self
66
+ end
67
+
68
+ def reset_style
69
+ @owed = nil
70
+ @style_state = {fg: nil, bg: nil, attrs: []}
71
+ @nodes << Node::Style.new(["default"])
72
+ self
73
+ end
74
+ # A shell command run by tmux (#(...)). Its text is tmux format source, so
75
+ # #{pane_id}-style codes expand before the shell sees it.
76
+ def command(value)
77
+ command = value.to_s
78
+ if Escape.unbalanced_parens?(command)
79
+ raise Error, "tmux cannot run #{command.inspect} in #(...): its parentheses are unbalanced; move it into a script"
80
+ end
81
+ settle
82
+ @nodes << Node::Command.new(command)
83
+ self
84
+ end
85
+
86
+ # Wrap the body produced by the block with surrounding text. Pass prefix and
87
+ # suffix directly (`wrap("[", "]")`), or pass a proc that receives a
88
+ # placeholder (`wrap ->(x) { " (#{x}) " }`). The active style is restored
89
+ # before the suffix, including styles selected by #switch_style.
90
+ def wrap(prefix_or_template, suffix = nil, &block)
91
+ if prefix_or_template.respond_to?(:call)
92
+ raise Error, "wrap cannot combine a template proc with a suffix" if suffix
93
+
94
+ marker = "\u0000"
95
+ prefix, suffix = prefix_or_template.call(marker).split(marker, 2)
96
+ else
97
+ prefix = prefix_or_template
98
+ suffix ||= ""
99
+ end
100
+ settle
101
+ entry_style = @style_state
102
+ text(prefix) unless prefix.to_s.empty?
103
+ body_style = fallback_style(entry_style)
104
+ unless body_style.eql?(entry_style)
105
+ restyle(body_style, force: true)
106
+ @style_state = body_style
107
+ end
108
+ previous_fallback_mode = @materialize_style_fallback
109
+ @materialize_style_fallback = true
110
+ evaluate(block) if block
111
+ @materialize_style_fallback = previous_fallback_mode
112
+ restyle(entry_style, force: true)
113
+ @style_state = entry_style
114
+ text(suffix) unless suffix.to_s.empty?
115
+ self
116
+ end
117
+
118
+ # Select a complete style at tmux render time. Unlike using Tmux.switch as
119
+ # one fg/bg value, this emits a conditional around whole #[...] style nodes,
120
+ # which mirrors hand-written tmux formats and can be restored by #wrap.
121
+ def switch_style(cases)
122
+ settle
123
+ default = cases.fetch(:else) { raise Error, "switch_style needs an else: style" }
124
+ branches = cases.reject { |condition, _| condition.equal?(:else) }
125
+ base = @style_state
126
+ nodes, state = style_branch(default, base)
127
+
128
+ branches.reverse_each do |condition, attributes|
129
+ on_true, true_state = style_branch(attributes, base)
130
+ expression = Tmux.expression(condition)
131
+ nodes = [Node::Conditional.new(expression, on_true, nodes)]
132
+ state = conditional_style_state(expression, true_state, state)
133
+ end
134
+
135
+ @nodes.concat(nodes)
136
+ @style_state = state
137
+ self
138
+ end
139
+
140
+ # A FLAG is a self-contained chunk of the bar: given colors + content it
141
+ # emits both edges, the fg/bg swap, and the attr reset. left_ is for the
142
+ # right-aligned bar (left-pointing edges); right_ for the left-aligned bar.
143
+ # `content` may be the first positional arg or the `content:` keyword (a
144
+ # String or an Expression like Tmux.pane_mode); a block body is emitted
145
+ # verbatim instead. `p:` is the number of space pad chars on each side of the
146
+ # content (default 1; 0 for none). `over:` is the color behind the pointed
147
+ # edge: the previous background for left_ helpers, :default for right_.
148
+ # `notch_over:` can give a flag's notch a different surrounding color;
149
+ # by default it uses the same color as `over:`.
150
+ # `attrs:` styles the content only, never the edges (terminals draw bold
151
+ # text in a named color's bright variant, which would recolor a bold
152
+ # edge), and is reset after the chunk so it never leaks. `glyph:` overrides the edge shape. Pass `close: false` for
153
+ # an open flag, or use left_segment/right_segment.
154
+ def left_flag(content_arg = nil, bg:, fg:, content: nil, p: 1, attrs: nil, over: nil, notch_over: nil, glyph: :triangle, close: true, &block)
155
+ content = content_arg unless content_arg.nil?
156
+ flag(Glyph.resolve(glyph, side: :left), leading: true, bg:, fg:, content:, p:, attrs:, over:, notch_over:, close:, &block)
157
+ end
158
+
159
+ def right_flag(content_arg = nil, bg:, fg:, content: nil, p: 1, attrs: nil, over: nil, notch_over: nil, glyph: :triangle, close: true, &block)
160
+ content = content_arg unless content_arg.nil?
161
+ flag(Glyph.resolve(glyph, side: :right), leading: false, bg:, fg:, content:, p:, attrs:, over:, notch_over:, close:, &block)
162
+ end
163
+
164
+ # A SEGMENT is a chain piece: one shared edge, no closing glyph — it butts
165
+ # against its neighbor. Same options as a flag minus close.
166
+ def left_segment(content_arg = nil, bg:, fg:, content: nil, p: 1, attrs: nil, over: nil, glyph: :triangle, &block)
167
+ content = content_arg unless content_arg.nil?
168
+ flag(Glyph.resolve(glyph, side: :left), leading: true, bg:, fg:, content:, p:, attrs:, over:, close: false, &block)
169
+ end
170
+
171
+ def right_segment(content_arg = nil, bg:, fg:, content: nil, p: 1, attrs: nil, over: nil, glyph: :triangle, &block)
172
+ content = content_arg unless content_arg.nil?
173
+ flag(Glyph.resolve(glyph, side: :right), leading: false, bg:, fg:, content:, p:, attrs:, over:, close: false, &block)
174
+ end
175
+
176
+ # A CHIP is a floating double-ended badge: a left glyph in, the content, and
177
+ # a right glyph out, both edges in the chip's `bg` over the surrounding color
178
+ # (`over:`, default :default). `content`/`fg`/`bg`/`p:` work like a segment;
179
+ # a block body is emitted verbatim. Style is reset afterward, so the chip is
180
+ # fully self-contained. With two thin edges, the text uses the outline
181
+ # color and the body shares the edges' surrounding background.
182
+ def chip(content_arg = nil, bg:, fg:, content: nil, p: 1, over: :default, left: :triangle, right: :triangle, &block)
183
+ content = content_arg unless content_arg.nil?
184
+ left_glyph = Glyph.resolve(left, side: :left)
185
+ right_glyph = Glyph.resolve(right, side: :right)
186
+ thin = Glyph.thin?(left_glyph) && Glyph.thin?(right_glyph)
187
+ style(fg: bg, bg: over)
188
+ text left_glyph
189
+ style(fg: thin ? bg : fg, bg: thin ? over : bg)
190
+ emit_body(content, p, block)
191
+ style(fg: bg, bg: over)
192
+ text right_glyph
193
+ reset_style
194
+ self
195
+ end
196
+
197
+ # Emit a style. `attrs`/`fg`/`bg` set the style for what follows. With a
198
+ # block, the style applies only to the block's body, padded with `p`
199
+ # spaces each side (default 0), and the style from before the block is
200
+ # restored after it: `style(fg: x, p: 1) { append y }` is style, " ", body,
201
+ # " ", then the previous style again.
202
+ def style(attributes = nil, p: 0, **keywords, &block)
203
+ properties = style_properties(attributes)
204
+ properties.merge!(keywords)
205
+ values, operations = style_operations(properties)
206
+ raise Error, "style requires at least one attribute" if values.empty? && !block
207
+
208
+ entry_style = @style_state
209
+ if values.empty?
210
+ settle
211
+ elsif @owed
212
+ # Straight from the previous block's style to this one: what was
213
+ # written, plus whatever undoes the block and this doesn't override.
214
+ target = apply_style(@style_state, operations)
215
+ @nodes.concat(transition_nodes(@owed.first, target, written: values))
216
+ @owed = nil
217
+ @style_state = target
218
+ else
219
+ @nodes << Node::Style.new(values)
220
+ @style_state = apply_style(@style_state, operations)
221
+ end
222
+ return self unless block
223
+
224
+ pad = " " * p
225
+ text(pad) unless pad.empty?
226
+ evaluate(block)
227
+ text(pad) unless pad.empty?
228
+ settle
229
+ @owed = [@style_state, entry_style]
230
+ @style_state = entry_style
231
+ self
232
+ end
233
+
234
+ def expand_option(name)
235
+ settle
236
+ @nodes << Node::ExpandOption.new(Tmux.normalize_user_option(name))
237
+ self
238
+ end
239
+
240
+ def if?(condition, &block) = conditional(condition, on_true: block)
241
+ def switch(cases) = Tmux.switch(cases)
242
+
243
+ def conditional(condition, on_true: nil, on_false: nil)
244
+ settle
245
+ expression = Tmux.expression(condition)
246
+ entry_style = @style_state
247
+ success = capture(on_true, style_state: entry_style)
248
+ failure = capture(on_false, style_state: entry_style)
249
+ index = @nodes.size
250
+ @nodes << Node::Conditional.new(expression, success.nodes, failure.nodes)
251
+ context = @conditional_states[index] = {
252
+ entry_style:, branches: [[expression, on_true, success]], failure:,
253
+ has_else: !on_false.nil?
254
+ }
255
+ rebuild_conditional(index, context)
256
+ FormatConditional.new(self, index)
257
+ end
258
+
259
+ def fill_elsif(index, condition, block)
260
+ context = open_conditional(index, ".elsif?")
261
+ expression = Tmux.expression(condition)
262
+ branch = capture(block, style_state: context.fetch(:entry_style))
263
+ context.fetch(:branches) << [expression, block, branch]
264
+ rebuild_conditional(index, context)
265
+ end
266
+
267
+ # Fills the final fallback of the conditional just appended.
268
+ def fill_else(index, block)
269
+ context = open_conditional(index, ".else")
270
+ context[:failure] = capture(block, style_state: context.fetch(:entry_style))
271
+ context[:has_else] = true
272
+ rebuild_conditional(index, context)
273
+ end
274
+
275
+
276
+ def build = Format.new(@nodes.dup)
277
+
278
+ private
279
+
280
+ def open_conditional(index, method)
281
+ context = @conditional_states[index]
282
+ unless @nodes[index].is_a?(Node::Conditional) && context
283
+ raise Error, "#{method} must directly follow the if? it belongs to"
284
+ end
285
+ raise Error, "this conditional already has an else branch" if context[:has_else]
286
+
287
+ context
288
+ end
289
+
290
+ # Captured blocks run once. Rebuild only their nested false branches and
291
+ # style snapshots as elsif? / else branches are added to the chain.
292
+ def rebuild_conditional(index, context)
293
+ entry_style = context.fetch(:entry_style)
294
+ failure = context.fetch(:failure)
295
+ nodes = failure.nodes
296
+ state = failure.style_state
297
+ style_only = style_nodes_only?(nodes)
298
+ has_fallback = context.fetch(:has_else)
299
+
300
+ context.fetch(:branches).reverse_each do |condition, block, branch|
301
+ style_only &&= style_nodes_only?(branch.nodes)
302
+ success_nodes = branch.nodes
303
+ if style_only && @materialize_style_fallback
304
+ success_nodes = inherited_style_nodes(entry_style) unless block
305
+ nodes = inherited_style_nodes(entry_style) unless has_fallback
306
+ end
307
+ nodes = [Node::Conditional.new(condition, success_nodes, nodes)]
308
+ state = if style_only
309
+ conditional_style_state(condition, branch.style_state, state)
310
+ else
311
+ entry_style
312
+ end
313
+ has_fallback = true
314
+ end
315
+
316
+ @nodes[index] = nodes.first
317
+ @style_state = state
318
+ end
319
+
320
+ # Emits a restore owed by a style block, before content that should be
321
+ # drawn in the restored style.
322
+ def settle
323
+ return unless @owed
324
+
325
+ from, to = @owed
326
+ @owed = nil
327
+ @nodes.concat(transition_nodes(from, to))
328
+ end
329
+
330
+ # Style nodes that change tmux's current style `from` into `to`, starting
331
+ # from the style values the configuration `written` itself.
332
+ def transition_nodes(from, to, written: [])
333
+ plain = [from, to].none? { |state| state.is_a?(ConditionalStyleState) }
334
+ dropped = plain ? from[:attrs] - to[:attrs] : []
335
+ unless plain && dropped.none? { |attr| attr.include?("=") }
336
+ # Attributes such as align=right can't be switched off by name.
337
+ return [Node::Style.new(["default"]), *style_state_nodes(to)]
338
+ end
339
+
340
+ values = written.dup
341
+ %i[fg bg].each do |name|
342
+ next if from[name].eql?(to[name]) || values.any? { |value| value.start_with?("#{name}=") }
343
+
344
+ values << "#{name}=#{to[name] || 'default'}"
345
+ end
346
+ values.concat(to[:attrs] - from[:attrs] - values)
347
+ values.concat(dropped.map { |attr| "no#{attr}" } - values)
348
+ values.empty? ? [] : [Node::Style.new(values)]
349
+ end
350
+
351
+ public
352
+
353
+
354
+ private
355
+
356
+ # Splits a string into literal text and balanced #{...} format chunks.
357
+ def interpolated_parts(string)
358
+ parts = []
359
+ literal = +""
360
+ index = 0
361
+ while index < string.length
362
+ if string[index, 2] == "\#{"
363
+ closing = matching_brace(string, index + 1)
364
+ raise Error, "text #{string.inspect} has an unclosed \#{" unless closing
365
+
366
+ parts << [:text, literal] unless literal.empty?
367
+ parts << [:format, string[index..closing]]
368
+ literal = +""
369
+ index = closing + 1
370
+ else
371
+ literal << string[index]
372
+ index += 1
373
+ end
374
+ end
375
+ parts << [:text, literal] unless literal.empty?
376
+ parts
377
+ end
378
+
379
+ def matching_brace(string, open_index)
380
+ depth = 0
381
+ (open_index...string.length).each do |index|
382
+ depth += 1 if string[index] == "{"
383
+ depth -= 1 if string[index] == "}"
384
+ return index if depth.zero?
385
+ end
386
+ nil
387
+ end
388
+
389
+ # style(fg: :red), style(:bold), style([:bold, :italics]), or a Hash.
390
+ def style_properties(attributes)
391
+ case attributes
392
+ when nil then {}
393
+ when Hash then attributes.dup
394
+ when Symbol, String, Array then {attrs: attributes}
395
+ else
396
+ raise Error, "style expects a Hash of fg:/bg:/attrs: or attribute names, got #{attributes.inspect}"
397
+ end
398
+ end
399
+
400
+ def style_color(value) = value.is_a?(Expression) ? value.source : value.to_s
401
+
402
+ def style_operations(properties)
403
+ values = []
404
+ operations = []
405
+ properties.each do |name, value|
406
+ if name.equal?(:attrs)
407
+ Array(value).map(&:to_s).each do |attribute|
408
+ values << attribute
409
+ operations << [:attr, attribute]
410
+ end
411
+ elsif %i[fg bg].include?(name)
412
+ raise Error, "style #{name}: needs a color, got nil" if value.nil?
413
+
414
+ values << "#{name}=#{style_color(value)}"
415
+ operations << [name, value]
416
+ else
417
+ raise Error, "unknown style attribute: #{name}"
418
+ end
419
+ end
420
+ [values, operations]
421
+ end
422
+
423
+ def apply_style(state, operations)
424
+ if state.is_a?(ConditionalStyleState)
425
+ conditional_style_state(
426
+ state.condition,
427
+ apply_style(state.on_true, operations),
428
+ apply_style(state.on_false, operations)
429
+ )
430
+ else
431
+ result = state.merge(attrs: state[:attrs].dup)
432
+ operations.each do |name, value|
433
+ if name == :attr
434
+ if value == "default" || value == "none"
435
+ result = {fg: nil, bg: nil, attrs: []}
436
+ elsif value.start_with?("no") && value.length > 2
437
+ result[:attrs].delete(value.delete_prefix("no"))
438
+ else
439
+ result[:attrs] << value unless result[:attrs].include?(value)
440
+ end
441
+ else
442
+ result[name] = value
443
+ end
444
+ end
445
+ result
446
+ end
447
+ end
448
+
449
+ def conditional_style_state(condition, on_true, on_false)
450
+ on_true.eql?(on_false) ? on_true : ConditionalStyleState.new(condition, on_true, on_false)
451
+ end
452
+
453
+ def fallback_style(state)
454
+ state.is_a?(ConditionalStyleState) ? fallback_style(state.on_false) : state
455
+ end
456
+
457
+ def style_branch(attributes, base)
458
+ properties = attributes.respond_to?(:to_h) ? attributes.to_h : {}
459
+ values, operations = style_operations(properties)
460
+ nodes = values.empty? ? [] : [Node::Style.new(values)]
461
+ [nodes, apply_style(base, operations)]
462
+ end
463
+
464
+ # Re-emit a captured style snapshot. Conditional snapshots remain
465
+ # conditional, allowing a wrapper suffix to inherit the same runtime style
466
+ # as its prefix.
467
+ def restyle(state, force: false)
468
+ settle
469
+ @nodes.concat(style_state_nodes(state, force:))
470
+ end
471
+
472
+ def style_state_nodes(state, force: false)
473
+ if state.is_a?(ConditionalStyleState)
474
+ [Node::Conditional.new(
475
+ state.condition,
476
+ style_state_nodes(state.on_true, force: true),
477
+ style_state_nodes(state.on_false, force: true)
478
+ )]
479
+ else
480
+ values = []
481
+ values << "fg=#{state[:fg]}" if state[:fg]
482
+ values << "bg=#{state[:bg]}" if state[:bg]
483
+ values.concat(state[:attrs])
484
+ values = ["default"] if values.empty? && force
485
+ values.empty? ? [] : [Node::Style.new(values)]
486
+ end
487
+ end
488
+
489
+ # Runs a branch block on this same builder with a fresh node buffer, so
490
+ # helper methods and instance variables stay visible in nested blocks.
491
+ def capture(block, style_state: @style_state)
492
+ return CapturedFormat.new([], style_state) unless block
493
+
494
+ saved = [@nodes, @style_state, @conditional_states, @owed]
495
+ @nodes = []
496
+ @style_state = style_state
497
+ @conditional_states = {}
498
+ @owed = nil
499
+ evaluate(block)
500
+ settle
501
+ CapturedFormat.new(@nodes, @style_state)
502
+ ensure
503
+ @nodes, @style_state, @conditional_states, @owed = saved if saved
504
+ end
505
+
506
+ def style_nodes_only?(nodes)
507
+ nodes.all? do |node|
508
+ node.is_a?(Node::Style) ||
509
+ (node.is_a?(Node::Conditional) &&
510
+ style_nodes_only?(node.on_true) && style_nodes_only?(node.on_false))
511
+ end
512
+ end
513
+
514
+ def inherited_style_nodes(state)
515
+ default_style?(state) ? [] : style_state_nodes(state, force: true)
516
+ end
517
+
518
+ def default_style?(state)
519
+ !state.is_a?(ConditionalStyleState) &&
520
+ state[:fg].nil? && state[:bg].nil? && state[:attrs].empty?
521
+ end
522
+
523
+ def flag(edge_char, leading:, bg:, fg:, content:, p:, attrs:, over:, close:, notch_over: nil, &block)
524
+ # A leading edge sits over the previous chip's bg, which the style tracker
525
+ # already knows — so `over:` defaults to it (falling back to :default).
526
+ over = (leading ? style_value(@style_state, :bg) : nil) || :default if over.nil?
527
+ notch_over = over if notch_over.nil?
528
+ body_style = attrs ? {attrs: attrs} : {}
529
+ # A flag's notch is cut into the end opposite its point: after the body
530
+ # of a left flag, before the body of a right flag.
531
+ notch(edge_char, bg:, over: notch_over) if close && !leading
532
+ if leading
533
+ style(fg: bg, bg: over)
534
+ text edge_char
535
+ end
536
+ style({fg: fg, bg: bg}.merge(body_style))
537
+ emit_body(content, p, block)
538
+ unless leading
539
+ style(fg: bg, bg: over)
540
+ text edge_char
541
+ end
542
+ # Attrs never leak past the chunk.
543
+ reset_attrs(attrs)
544
+ notch(edge_char, bg:, over: notch_over) if close && leading
545
+ self
546
+ end
547
+
548
+ # The notch is cut in the color behind the flag, so the next block starts
549
+ # from that color. tmux can't name the terminal's own background as a
550
+ # foreground color, so the glyph is drawn reversed: the cell takes the
551
+ # flag's color and the glyph takes `over`, even when `over` is :default.
552
+ def notch(edge_char, bg:, over:)
553
+ style(fg: bg, bg: over, attrs: :reverse)
554
+ text edge_char
555
+ style(attrs: :noreverse)
556
+ end
557
+
558
+ def reset_attrs(attrs)
559
+ off = Array(attrs).map { |attr| "no#{attr}" }
560
+ style(attrs: off) unless off.empty?
561
+ end
562
+
563
+ def style_value(state, name)
564
+ return state[name] unless state.is_a?(ConditionalStyleState)
565
+
566
+ on_true = style_value(state.on_true, name) || :default
567
+ on_false = style_value(state.on_false, name) || :default
568
+ return on_true if on_true.eql?(on_false)
569
+
570
+ Tmux.switch(state.condition => on_true, else: on_false)
571
+ end
572
+
573
+ # Emit a flag/segment body: a block verbatim, otherwise `content` padded with
574
+ # `count` spaces each side. A String is literal text, an Expression stays
575
+ # dynamic, and an Array mixes both like #text.
576
+ def emit_body(content, count, block)
577
+ return evaluate(block) if block
578
+ return if content.nil?
579
+
580
+ pad = " " * count
581
+ text(pad) unless pad.empty?
582
+ text(*Array(content))
583
+ text(pad) unless pad.empty?
584
+ end
585
+ end
586
+ end