tmux 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 225f31971e201d9baeeec68e3f920d8a72f727012a15e0617bb09d551a0af4a9
4
- data.tar.gz: 103f4cbd898b274d9901e825c3eecb95fdd534d2178fe6a59428066e58448f59
3
+ metadata.gz: 94ce03b8025bac7e2d13c5732de26e231259eb402c23c727bb071eece1e6bc6c
4
+ data.tar.gz: 1c1d49bdc328d9432c06097b9a7d4024a30cb8098fc1d69d1c529a695560072f
5
5
  SHA512:
6
- metadata.gz: 3006eeaaca5dbf9449c9c2f8081096cc4c172c1f2cb3b1933348409b4a9f231522749213e025f0a187e0ceccbb2eaf63a6905fca3f772d8f58682f6ab5034bbf
7
- data.tar.gz: 316d61e9d40ae7c52f4352f350d38cb4f78c9b42c5bc047b2cab32d04ab66c4f1a646eeea52de19bee5b6f7111e29db325167381d5203410b5b617ef00228f8f
6
+ metadata.gz: ca1563f81d135988a54d60342f89dcaa3ca5d1ebc6a40a474ac7a2338ee2f7eb7826c948b2f4c8bf101e5f6cac754a3d79c64eb4b981fdd6b75cf54410efd984
7
+ data.tar.gz: dd67416dba50395f82d136a9327bb72093c4b4618f64e31838b8aaf146e3309dbc4432e09460c882c1c182108840ccaedd1fba2b447c09751d5d42916954ea6e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,32 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.2.0 (2026-10-23)
4
+
5
+ ### Library
6
+
7
+ - `bind(key)` with no command or block returns a chain of named commands:
8
+ `bind("-").split(:vertical).cwd(:current)`, `bind("c").new_window`.
9
+ - Named binding commands `split`, `new_window`, `cwd`, `resize_pane`, `zoom`,
10
+ `kill_pane`, `kill_window`, and `source`, and a direction for
11
+ `select_pane`. `split` accepts `:vertical`/`:horizontal` or the shorthand
12
+ `:v`/`:h`. They work in chains and in binding blocks.
13
+ - `comment` writes `#` lines into the generated file, and `hook` sets a
14
+ tmux hook (command string or block, optional `index:` slot, `:global`,
15
+ `:window`, or `:pane` scope).
16
+
17
+ ### Fixes
18
+
19
+ - `shell(...)` conditions reach the shell exactly as written. tmux no longer
20
+ expands `$` variables when the file loads or rejects shell syntax such as
21
+ `${VAR:-default}`.
22
+
23
+ ### Documentation
24
+
25
+ - The README opens with a hand-written tmux.conf beside its tmux.rb version,
26
+ and Examples move up ahead of Why. Tests check that both versions load
27
+ identically and that the key binding examples match their tmux comments.
28
+
29
+ ## 0.1.0 (2026-10-21)
4
30
 
5
31
  First public release, as the `tmux` gem (`require "tmux"`), with the
6
32
  `tmux.rb` command (also installed as `tmux_rb`).
data/README.md CHANGED
@@ -3,6 +3,55 @@
3
3
  tmux.rb is a Ruby DSL that compiles readable, structured block-based
4
4
  configuration into native tmux configuration and format strings.
5
5
 
6
+ ## At a glance
7
+
8
+ A small, hand-written `~/.tmux.conf`:
9
+
10
+ <!-- glance: tmux -->
11
+ ```tmux
12
+ set -g prefix C-a
13
+ unbind C-b
14
+ bind C-a send-prefix
15
+
16
+ set -g mouse on
17
+ set -g base-index 1
18
+ setw -g mode-keys vi
19
+
20
+ bind | split-window -h -c "#{pane_current_path}"
21
+ bind - split-window -v -c "#{pane_current_path}"
22
+ bind r source-file ~/.tmux.conf \; display-message "Reloaded"
23
+
24
+ set -g status-right "#{?window_zoomed_flag,#[fg=yellow]ZOOM #[fg=default],}#S"
25
+ ```
26
+
27
+ The same configuration in tmux.rb, which compiles to a file tmux loads
28
+ identically:
29
+
30
+ <!-- glance: ruby -->
31
+ ```ruby
32
+ Tmux.configure do
33
+ global.prefix = "C-a"
34
+ unbind "C-b"
35
+ bind("C-a").command(:send_prefix)
36
+
37
+ global.mouse = true
38
+ global.base_index = 1
39
+ window.mode_keys = :vi
40
+
41
+ bind("|").split(:horizontal, cwd: :current)
42
+ bind("-").split(:vertical, cwd: :current)
43
+ bind("r").source("~/.tmux.conf").display_message("Reloaded")
44
+
45
+ global.status.right = Tmux.format do
46
+ if?(Tmux.window_zoomed_flag) { style(fg: :yellow) { text "ZOOM " } }
47
+ text Tmux.session_name
48
+ end
49
+ end
50
+ ```
51
+
52
+ The payoff grows with the configuration. Status lines are where hand-written
53
+ tmux gets hardest to read, and where the format DSL helps most:
54
+
6
55
  <!-- example: intro -->
7
56
  ```ruby
8
57
  # ~/.config/tmux/tmux.rb
@@ -19,7 +68,7 @@ Tmux.configure do
19
68
  left_segment "#{Tmux.session_name}@#{Tmux.host}", bg: :blue, fg: :black
20
69
  end
21
70
 
22
- bind "|", %q{split-window -h -c "#{pane_current_path}"}
71
+ bind("|").split(:horizontal, cwd: :current)
23
72
  end
24
73
  ```
25
74
 
@@ -43,6 +92,18 @@ the status bar set to the terminal's own colors. Named colors such as `:blue`,
43
92
  everywhere. Powerline edges need a font with Powerline glyphs, such as a
44
93
  [Nerd Font](https://www.nerdfonts.com).
45
94
 
95
+ ## Examples
96
+
97
+ - [examples/author_config.rb](https://github.com/cosmicbuffalo/tmux.rb/blob/main/examples/author_config.rb)
98
+ is the author's complete tmux configuration: structured status bars, pane
99
+ borders, and window tabs, plus integration with
100
+ [tmux-ai-attn](https://github.com/cosmicbuffalo/tmux-ai-attn).
101
+ - [examples/popular_configs](https://github.com/cosmicbuffalo/tmux.rb/blob/main/examples/popular_configs/README.md)
102
+ contains ten ports of well-known public tmux configurations. Each pinned
103
+ upstream config sits beside its Ruby version for comparison.
104
+ - [examples/window_status.rb](https://github.com/cosmicbuffalo/tmux.rb/blob/main/examples/window_status.rb)
105
+ is a standalone format file.
106
+
46
107
  ## Why
47
108
 
48
109
  Hand-written tmux configuration gets hard to change once it grows: status
@@ -825,11 +886,74 @@ end
825
886
 
826
887
  ### Key bindings
827
888
 
828
- `bind` maps a key to a tmux command string or a block of commands. Use the
829
- block form to pass command arguments without quoting them by hand.
830
- `table:` picks a key table, `no_prefix: true` binds without the prefix (`-n`),
831
- `repeat: true` allows repeating (`-r`), and `note:` adds a description (`-N`).
832
- `unbind` removes a binding.
889
+ `bind` maps a key to commands. Name the commands in a chain, write a block of
890
+ commands, or pass a tmux command string. `table:` picks a key table,
891
+ `no_prefix: true` binds without the prefix (`-n`), `repeat: true` allows
892
+ repeating (`-r`), and `note:` adds a description (`-N`). `unbind` removes a
893
+ binding.
894
+
895
+ Given only a key, `bind` returns a chain. Each method adds a command, and
896
+ `cwd` sets the directory for the `split` or `new_window` before it:
897
+
898
+ ```ruby
899
+ Tmux.configure do
900
+ # tmux: bind-key "-" split-window -v -c "#{pane_current_path}"
901
+ bind("-").split(:vertical).cwd(:current)
902
+
903
+ # tmux: bind-key "|" split-window -h -l "30%" -c "~/src"
904
+ bind("|").split(:horizontal, size: "30%", cwd: "~/src")
905
+
906
+ # tmux: bind-key "c" new-window
907
+ bind("c").new_window
908
+
909
+ # Commands run in order: a named window, then back to the last pane.
910
+ # tmux: bind-key "C" new-window -n "scratch" -c "#{pane_current_path}" \; select-pane -t "{last}"
911
+ bind("C").new_window(name: "scratch").cwd(:current).select_pane(target: "{last}")
912
+
913
+ # tmux: bind-key -r "h" select-pane -L
914
+ bind("h", repeat: true).select_pane(:left)
915
+
916
+ # tmux: bind-key -r "<" resize-pane -L 10
917
+ bind("<", repeat: true).resize_pane(:left, 10)
918
+
919
+ # tmux: bind-key -n "M-z" resize-pane -Z
920
+ bind("M-z", no_prefix: true).zoom
921
+
922
+ # tmux: bind-key "r" source-file "~/.tmux.conf" \; display-message "Reloaded"
923
+ bind("r").source("~/.tmux.conf").display_message("Reloaded")
924
+ end
925
+ ```
926
+
927
+ `split` takes tmux's direction names: `:vertical` (`-v`) puts the new pane
928
+ below, and `:horizontal` (`-h`) puts it to the right. `:v` and `:h` are
929
+ shorthand for them. Pane directions are
930
+ `:left`, `:right`, `:up`, and `:down`. The named commands are `split`,
931
+ `new_window`, `select_pane`, `resize_pane`, `zoom`, `kill_pane`,
932
+ `kill_window`, `source`, `display_message`, `display_menu`, `copy_mode`, and
933
+ `load_config`. Use `command` for any other tmux command (its arguments are
934
+ quoted for you) and `raw` for a command written exactly as tmux expects it.
935
+
936
+ The block form uses the same methods, one command per line, so `cwd` on its
937
+ own line applies to the `split` above it:
938
+
939
+ ```ruby
940
+ Tmux.configure do
941
+ # tmux: bind-key "|" split-window -h -c "some-dir"
942
+ bind "|" do
943
+ split :h # or :horizontal
944
+ cwd "some-dir"
945
+ end
946
+
947
+ # tmux: bind-key "C" new-window -n "scratch" -c "#{pane_current_path}" \; select-pane -t "{last}"
948
+ bind "C" do
949
+ new_window name: "scratch"
950
+ cwd :current
951
+ select_pane target: "{last}"
952
+ end
953
+ end
954
+ ```
955
+
956
+ Command strings pass through as written:
833
957
 
834
958
  ```ruby
835
959
  Tmux.configure do
@@ -869,9 +993,8 @@ Tmux.configure do
869
993
  end
870
994
  ```
871
995
 
872
- A binding block offers `command` for any tmux command (its arguments are
873
- quoted for you), `select_pane`, `copy_mode`, `display_message`,
874
- `display_menu`, `load_config`, and `raw`:
996
+ Blocks suit longer bindings, such as a menu or a command with no named
997
+ helper:
875
998
 
876
999
  ```ruby
877
1000
  Tmux.configure do
@@ -888,10 +1011,10 @@ Tmux.configure do
888
1011
  end
889
1012
  end
890
1013
 
891
- # Any command, with quoted arguments; select_pane can target a pane.
892
- # tmux: bind-key "N" new-window "-c" "#{pane_current_path}" \; select-pane -t "{last}"
893
- bind "N" do
894
- command :new_window, "-c", "\#{pane_current_path}"
1014
+ # Any command, with quoted arguments.
1015
+ # tmux: bind-key "S" swap-pane "-s" "{last}" \; select-pane -t "{last}"
1016
+ bind "S" do
1017
+ command :swap_pane, "-s", "{last}"
895
1018
  select_pane target: "{last}"
896
1019
  end
897
1020
 
@@ -910,16 +1033,16 @@ return one and several bindings can share it:
910
1033
  Tmux.configure do
911
1034
  def split_here(direction)
912
1035
  commands do
913
- command :split_window, direction, "-c", "\#{pane_current_path}"
1036
+ split direction, cwd: :current
914
1037
  display_message "split"
915
1038
  end
916
1039
  end
917
1040
 
918
- # tmux: bind-key "|" split-window "-h" -c "#{pane_current_path}" \; display-message "split"
919
- bind "|", split_here("-h")
1041
+ # tmux: bind-key "|" split-window -h -c "#{pane_current_path}" \; display-message "split"
1042
+ bind "|", split_here(:horizontal)
920
1043
 
921
- # tmux: bind-key "-" split-window "-v" -c "#{pane_current_path}" \; display-message "split"
922
- bind "-", split_here("-v")
1044
+ # tmux: bind-key "-" split-window -v -c "#{pane_current_path}" \; display-message "split"
1045
+ bind "-", split_here(:vertical)
923
1046
  end
924
1047
  ```
925
1048
 
@@ -1004,6 +1127,27 @@ Tmux.configure do
1004
1127
  end
1005
1128
  ```
1006
1129
 
1130
+ ### Comments and hooks
1131
+
1132
+ `comment` writes `#` lines into the generated file, for whoever reads or edits
1133
+ it. `hook` sets a tmux hook from a command string or a block of commands, as
1134
+ `bind` does; `index:` gives it its own slot so setting it again replaces that
1135
+ slot rather than adding a copy, and `scope:` picks `:global` (session hooks),
1136
+ `:window`, or `:pane`.
1137
+
1138
+ ```ruby
1139
+ Tmux.configure do
1140
+ # # Snapshot sessions whenever one closes.
1141
+ comment "Snapshot sessions whenever one closes."
1142
+
1143
+ # tmux: set-hook -g "session-closed[100]" "run-shell -b \"save-sessions\""
1144
+ hook "session-closed", %q{run-shell -b "save-sessions"}, index: 100
1145
+
1146
+ # tmux: set-hook -gw "pane-focus-in" "display-message \"focused\""
1147
+ hook("pane-focus-in", scope: :window) { display_message "focused" }
1148
+ end
1149
+ ```
1150
+
1007
1151
  ### Raw commands and composition
1008
1152
 
1009
1153
  `raw` passes tmux commands through verbatim (one per line; a trailing `\`
@@ -1030,18 +1174,6 @@ Tmux.configure do
1030
1174
  end
1031
1175
  ```
1032
1176
 
1033
- ## Examples
1034
-
1035
- - [examples/author_config.rb](https://github.com/cosmicbuffalo/tmux.rb/blob/main/examples/author_config.rb)
1036
- is the author's complete tmux configuration: structured status bars, pane
1037
- borders, and window tabs, plus integration with
1038
- [tmux-ai-attn](https://github.com/cosmicbuffalo/tmux-ai-attn).
1039
- - [examples/popular_configs](https://github.com/cosmicbuffalo/tmux.rb/blob/main/examples/popular_configs/README.md)
1040
- contains ten ports of well-known public tmux configurations. Each pinned
1041
- upstream config sits beside its Ruby version for comparison.
1042
- - [examples/window_status.rb](https://github.com/cosmicbuffalo/tmux.rb/blob/main/examples/window_status.rb)
1043
- is a standalone format file.
1044
-
1045
1177
  ## Development
1046
1178
 
1047
1179
  ```bash
@@ -32,6 +32,20 @@ module Tmux
32
32
  # separate entries until the binding is compiled, so callers never write tmux
33
33
  # command separators or quote command arguments by hand.
34
34
  class CommandBuilder
35
+ # A command built by one of the named helpers below, kept in parts so a
36
+ # modifier such as #cwd can still add a flag before it compiles.
37
+ Typed = Struct.new(:name, :flags) do
38
+ def to_s = [name, *flags].join(" ")
39
+ end
40
+ private_constant :Typed
41
+
42
+ # tmux's own names: a vertical split (-v) stacks the new pane below, a
43
+ # horizontal one (-h) puts it to the right. :v and :h are shorthand.
44
+ SPLIT_DIRECTIONS = { vertical: "-v", v: "-v", horizontal: "-h", h: "-h" }.freeze
45
+ PANE_DIRECTIONS = { left: "-L", right: "-R", up: "-U", down: "-D" }.freeze
46
+ CWD_COMMANDS = %w[split-window new-window].freeze
47
+ private_constant :SPLIT_DIRECTIONS, :PANE_DIRECTIONS, :CWD_COMMANDS
48
+
35
49
  def initialize = @commands = []
36
50
 
37
51
  # Any tmux command. Arguments are quoted for you; tmux expands $VARIABLES
@@ -48,13 +62,59 @@ module Tmux
48
62
  self
49
63
  end
50
64
 
51
- def select_pane(target: nil)
65
+ # split-window. `cwd:` starts the new pane in a directory; :current is the
66
+ # active pane's directory. `size:` is lines/columns or a "30%" string.
67
+ def split(direction, cwd: nil, size: nil)
68
+ flag = SPLIT_DIRECTIONS.fetch(direction) do
69
+ raise Error, "split direction must be :vertical (:v) or :horizontal (:h), not #{direction.inspect}"
70
+ end
71
+ flags = [flag]
72
+ flags.concat(["-l", ConfigCompiler.quote(size)]) if size
73
+ typed("split-window", flags, cwd:)
74
+ end
75
+
76
+ def new_window(name: nil, cwd: nil)
77
+ flags = []
78
+ flags.concat(["-n", ConfigCompiler.quote(name)]) if name
79
+ typed("new-window", flags, cwd:)
80
+ end
81
+
82
+ # Start the previous command (split or new_window) in `directory`;
83
+ # :current is the active pane's directory.
84
+ def cwd(directory)
85
+ last = @commands.last
86
+ unless last.is_a?(Typed) && CWD_COMMANDS.include?(last.name)
87
+ raise Error, "cwd must follow split or new_window"
88
+ end
89
+ raise Error, "#{last.name} already has a directory" if last.flags.include?("-c")
90
+
91
+ last.flags.concat(["-c", ConfigCompiler.quote(directory == :current ? "\#{pane_current_path}" : directory)])
92
+ self
93
+ end
94
+
95
+ # select-pane, by direction (:left, :right, :up, :down) and/or target.
96
+ def select_pane(direction = nil, target: nil)
52
97
  parts = ["select-pane"]
98
+ parts << pane_direction(direction) if direction
53
99
  parts.concat(["-t", ConfigCompiler.quote(target)]) if target
54
100
  @commands << parts.join(" ")
55
101
  self
56
102
  end
57
103
 
104
+ # resize-pane by `amount` cells (tmux's default is 1) in a direction.
105
+ def resize_pane(direction, amount = nil)
106
+ @commands << ["resize-pane", pane_direction(direction), amount&.to_i].compact.join(" ")
107
+ self
108
+ end
109
+
110
+ # Toggle the active pane's zoom (resize-pane -Z).
111
+ def zoom = raw("resize-pane -Z")
112
+ def kill_pane = raw("kill-pane")
113
+ def kill_window = raw("kill-window")
114
+
115
+ # source-file; `quiet: true` (-q) skips a missing file without an error.
116
+ def source(path, quiet: false) = raw("source-file#{' -q' if quiet} #{ConfigCompiler.quote(path)}")
117
+
58
118
  # Send one copy-mode command (`send-keys -X`) to a pane. `count:` maps to
59
119
  # tmux's -N repeat count, used by actions such as scroll-up and scroll-down.
60
120
  def copy_mode(action, target: nil, count: nil)
@@ -91,14 +151,52 @@ module Tmux
91
151
  self
92
152
  end
93
153
 
154
+ def empty? = @commands.empty?
155
+
94
156
  def build
95
157
  raise Error, "a command block must contain at least one command" if @commands.empty?
96
158
 
97
- @commands.join(" \\; ")
159
+ @commands.map(&:to_s).join(" \\; ")
98
160
  end
99
161
 
100
162
  private
101
163
 
164
+ def typed(name, flags, cwd: nil)
165
+ @commands << Typed.new(name, flags)
166
+ cwd ? self.cwd(cwd) : self
167
+ end
168
+
169
+ def pane_direction(direction)
170
+ PANE_DIRECTIONS.fetch(direction) do
171
+ raise Error, "pane direction must be :left, :right, :up, or :down, not #{direction.inspect}"
172
+ end
173
+ end
174
+
102
175
  def normalize(name) = name.to_s.tr("_", "-")
103
176
  end
177
+
178
+ # What `bind(key)` returns without a command or block. Each CommandBuilder
179
+ # method adds a command and rewrites the binding's configuration line, so the
180
+ # chain can stop anywhere: bind("c").new_window.cwd(:current)
181
+ class BindingChain
182
+ attr_reader :key
183
+
184
+ def initialize(key, prefix, nodes, index)
185
+ @key = key
186
+ @prefix = prefix
187
+ @nodes = nodes
188
+ @index = index
189
+ @commands = CommandBuilder.new
190
+ end
191
+
192
+ def empty? = @commands.empty?
193
+
194
+ (CommandBuilder.public_instance_methods(false) - %i[build empty?]).each do |name|
195
+ define_method(name) do |*arguments, **options, &block|
196
+ @commands.public_send(name, *arguments, **options, &block)
197
+ @nodes[@index] = ConfigNode::Command.new("#{@prefix} #{@commands.build}")
198
+ self
199
+ end
200
+ end
201
+ end
104
202
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Tmux
4
4
  # The context of a Tmux.configure block. Every DSL method returns the
5
- # builder (conditionals return a handle for `.else`), so calls can be chained.
5
+ # builder (conditionals return a handle for `.else`, and `bind(key)` a
6
+ # BindingChain), so calls can be chained.
6
7
  class ConfigBuilder
7
8
  include Evaluation
8
9
 
@@ -13,6 +14,7 @@ module Tmux
13
14
  @nodes = []
14
15
  @else_filled = {}
15
16
  @conditional_states = {}
17
+ @open_bindings = []
16
18
  end
17
19
 
18
20
  def switch(cases) = Tmux.switch(cases)
@@ -38,6 +40,32 @@ module Tmux
38
40
  self
39
41
  end
40
42
 
43
+ # A comment in the generated file: one "# " line per line of `text`, and a
44
+ # bare "#" for an empty one. tmux ignores comments; they are for whoever
45
+ # reads or edits the generated config.
46
+ def comment(text)
47
+ text.to_s.each_line(chomp: true) do |line|
48
+ @nodes << ConfigNode::Command.new(line.empty? ? "#" : "# #{line}")
49
+ end
50
+ self
51
+ end
52
+
53
+ # A hook, as set-hook: `name` fires `command`, a tmux command string or a
54
+ # block of commands as for #bind. `index:` gives the hook its own slot
55
+ # (name[index]), so setting it again replaces that slot instead of adding
56
+ # another copy. `scope:` is :global (session hooks), :window, or :pane.
57
+ def hook(name, command = nil, index: nil, scope: :global, &block)
58
+ raise ArgumentError, "hook #{name}: give a command or a block, not both" if command && block
59
+ raise ArgumentError, "hook #{name}: give a command or a block" unless command || block
60
+
61
+ command = commands(&block) if block
62
+ flag = {global: "-g", window: "-gw", pane: "-gp"}.fetch(scope) do
63
+ raise ArgumentError, "hook #{name}: scope must be :global, :window, or :pane"
64
+ end
65
+ slot = index.nil? ? name.to_s : "#{name}[#{Integer(index)}]"
66
+ raw "set-hook #{flag} #{ConfigCompiler.quote(slot)} #{ConfigCompiler.quote(command.to_s, expand: false)}"
67
+ end
68
+
41
69
  # Scoped options are generated from the selected target's complete catalog.
42
70
  def global = option_root(:global)
43
71
  def server = option_root(:server)
@@ -76,21 +104,29 @@ module Tmux
76
104
  ].compact.join(" ")
77
105
  end
78
106
 
107
+ # With neither a command nor a block, bind returns a chain that adds each
108
+ # command to the binding: `bind("-").split(:vertical).cwd(:current)`.
79
109
  def bind(key, command = nil, table: nil, repeat: false, no_prefix: false, note: nil, &block)
80
110
  raise Error, "bind cannot combine table: and no_prefix:" if table && no_prefix
81
111
  raise Error, "bind accepts either a command or a block, not both" if command && block
82
112
 
83
- command = commands(&block) if block
84
- raise Error, "bind requires a command or block" unless command
85
-
86
113
  parts = ["bind-key"]
87
114
  parts << "-r" if repeat
88
115
  parts << "-n" if no_prefix
89
116
  parts.concat(["-N", ConfigCompiler.quote(note)]) if note
90
117
  parts.concat(["-T", ConfigCompiler.quote(table)]) if table
91
118
  parts << ConfigCompiler.quote(key)
92
- parts << command
93
- raw parts.join(" ")
119
+ prefix = parts.join(" ")
120
+
121
+ unless command || block
122
+ chain = BindingChain.new(key, prefix, @nodes, @nodes.size)
123
+ @nodes << ConfigNode::Blank.new # replaced by the chain's first command
124
+ @open_bindings << chain
125
+ return chain
126
+ end
127
+
128
+ command = commands(&block) if block
129
+ raw "#{prefix} #{command}"
94
130
  end
95
131
 
96
132
  # source-file; `quiet: true` (-q) skips a missing file without an error.
@@ -152,7 +188,13 @@ module Tmux
152
188
  # package. Splices its nodes in call order.
153
189
  def use(component) = append(component.to_config)
154
190
 
155
- def build = Config.new(@nodes, target: target).validate!
191
+ def build
192
+ if (open = @open_bindings.find(&:empty?))
193
+ raise Error, "bind #{open.key.inspect} has no command: chain one, such as .split(:vertical), or pass a command or block"
194
+ end
195
+
196
+ Config.new(@nodes, target: target).validate!
197
+ end
156
198
 
157
199
  private
158
200
 
@@ -58,7 +58,10 @@ module Tmux
58
58
  parts = ["if-shell"]
59
59
  parts << "-b" if node.background
60
60
  parts << "-F" if node.format
61
- parts << self.class.quote(node.test)
61
+ # A shell test is for the shell: escape $ and a leading ~ so tmux's
62
+ # parser leaves them alone (it rejects shell syntax such as
63
+ # ${VAR:-default} in a double-quoted string).
64
+ parts << self.class.quote(node.test, expand: node.format)
62
65
  text = "#{parts.join(' ')} #{block(node.success)}"
63
66
  text += " #{block(node.failure)}" unless node.failure.empty?
64
67
  text
data/lib/tmux/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tmux
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tmux
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick deLannoy