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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE +22 -0
- data/README.md +1076 -0
- data/bin/tmux.rb +6 -0
- data/bin/tmux_rb +4 -0
- data/lib/tmux/cli.rb +224 -0
- data/lib/tmux/command_builder.rb +104 -0
- data/lib/tmux/condition_compiler.rb +127 -0
- data/lib/tmux/config.rb +40 -0
- data/lib/tmux/config_builder.rb +247 -0
- data/lib/tmux/config_compiler.rb +82 -0
- data/lib/tmux/dsl.rb +118 -0
- data/lib/tmux/errors.rb +5 -0
- data/lib/tmux/escape.rb +68 -0
- data/lib/tmux/evaluation.rb +18 -0
- data/lib/tmux/expression.rb +109 -0
- data/lib/tmux/format.rb +94 -0
- data/lib/tmux/format_builder.rb +586 -0
- data/lib/tmux/format_compiler.rb +41 -0
- data/lib/tmux/glyph.rb +45 -0
- data/lib/tmux/option_tree.rb +148 -0
- data/lib/tmux/target.rb +200 -0
- data/lib/tmux/version.rb +5 -0
- data/lib/tmux/zone_builder.rb +31 -0
- data/lib/tmux.rb +47 -0
- metadata +92 -0
data/lib/tmux/format.rb
ADDED
|
@@ -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
|