echoes 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c05df4a1ad800e1b7e982aa2577a2bd6c64bfe15ec4c630aff8a561b53992072
4
+ data.tar.gz: dc78e9e8813c237375818a76e87b3a756cf9431e0dfb78da331cb5e8b309ca5a
5
+ SHA512:
6
+ metadata.gz: 8fd75f4eff8db332e59669df7359c0a0a904d9b879da99dcd33aa4dfb053a28869f80e556b9f783c6e362b8b34397ffab264d59a0aeda9c76244ad639d187dfa
7
+ data.tar.gz: 53a1aa9d6ee94264a31fb68c9f94ff5289415540e48461031a9b91cf52b31f8706bd9fce89dee880b2d1546c94b25605c4955545fefa0475b5aeb4c2908b8920
data/CLAUDE.md ADDED
@@ -0,0 +1,33 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Echoes is a Ruby gem (currently a freshly scaffolded template at v0.1.0). Author: Akira Matsuda. Requires Ruby >= 3.2.0. Licensed under MIT.
8
+
9
+ ## Commands
10
+
11
+ - **Install dependencies:** `bin/setup`
12
+ - **Run all tests:** `bundle exec rake test` (or just `bundle exec rake`, test is the default task)
13
+ - **Run a single test file:** `bundle exec ruby -Ilib:test test/echoes_test.rb`
14
+ - **Run a single test method:** `bundle exec ruby -Ilib:test test/echoes_test.rb -n test_method_name`
15
+ - **Interactive console:** `bin/console`
16
+ - **Install gem locally:** `bundle exec rake install`
17
+
18
+ ## Architecture
19
+
20
+ Standard Ruby gem layout:
21
+
22
+ - `lib/echoes.rb` — Main module entry point (defines `Echoes` module)
23
+ - `lib/echoes/version.rb` — Version constant
24
+ - `sig/echoes.rbs` — RBS type signatures
25
+ - `test/` — Tests using **test-unit** framework (not minitest, not rspec)
26
+
27
+ ## Testing
28
+
29
+ Uses the **test-unit** gem (~> 3.0). Test classes inherit from `Test::Unit::TestCase`. Test helper is at `test/test_helper.rb`.
30
+
31
+ ## CI
32
+
33
+ GitHub Actions runs `bundle exec rake` on push to master and on pull requests (Ruby 4.1.0, ubuntu-latest).
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key>
6
+ <string>Echoes</string>
7
+ <key>CFBundleIdentifier</key>
8
+ <string>com.github.amatsuda.echoes</string>
9
+ <key>CFBundleVersion</key>
10
+ <string>0.2.0</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>Echoes</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>APPL</string>
15
+ </dict>
16
+ </plist>
@@ -0,0 +1,50 @@
1
+ #!/bin/bash
2
+ # LaunchServices may pick the x86_64 slice of /bin/bash when launching
3
+ # this script-based .app bundle (script-based bundles don't get the
4
+ # Get Info "Open using Rosetta" toggle, but they're not necessarily
5
+ # launched native either). If bash runs translated, Ruby and every
6
+ # child rubish forks downstream — including brew — inherits the
7
+ # translated arch and may bail with "Cannot install under Rosetta 2".
8
+ # Force native arch on Apple Silicon to break that inheritance.
9
+
10
+ set -e
11
+
12
+ # Derive ECHOES_ROOT from the script's own location so the .app works
13
+ # inside any clone of the repo regardless of homedir or path. The
14
+ # script lives at <repo>/Echoes.app/Contents/MacOS/Echoes, so root
15
+ # is three levels up.
16
+ SCRIPT="${BASH_SOURCE[0]}"
17
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
18
+ ECHOES_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
19
+ ECHOES_LIB="$ECHOES_ROOT/lib"
20
+ ECHOES_BIN="$ECHOES_ROOT/exe/echoes"
21
+
22
+ # Pick a Ruby interpreter. Honor an explicit override via $RUBY,
23
+ # otherwise probe common locations (rbenv, homebrew, system) and
24
+ # fall back to whatever `env ruby` finds. Echoes requires Ruby
25
+ # >= 3.2; the first match wins, so adjust your PATH or set $RUBY
26
+ # if you need a specific version.
27
+ if [ -z "${RUBY:-}" ]; then
28
+ for cand in \
29
+ "$HOME/.rbenv/shims/ruby" \
30
+ /opt/homebrew/bin/ruby \
31
+ /usr/local/bin/ruby \
32
+ /usr/bin/ruby
33
+ do
34
+ if [ -x "$cand" ]; then
35
+ RUBY="$cand"
36
+ break
37
+ fi
38
+ done
39
+ fi
40
+ RUBY="${RUBY:-$(command -v ruby || true)}"
41
+ if [ -z "$RUBY" ] || [ ! -x "$RUBY" ]; then
42
+ echo "Echoes: no Ruby interpreter found. Set \$RUBY or install one." >&2
43
+ exit 127
44
+ fi
45
+
46
+ if [ "$(/usr/sbin/sysctl -in hw.optional.arm64)" = "1" ]; then
47
+ exec /usr/bin/arch -arm64 "$RUBY" -I"$ECHOES_LIB" "$ECHOES_BIN"
48
+ else
49
+ exec "$RUBY" -I"$ECHOES_LIB" "$ECHOES_BIN"
50
+ fi
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key>
6
+ <string>EchoesEmbed</string>
7
+ <key>CFBundleIdentifier</key>
8
+ <string>com.github.amatsuda.echoes-embed</string>
9
+ <key>CFBundleVersion</key>
10
+ <string>0.2.0</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>EchoesEmbed</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>APPL</string>
15
+ </dict>
16
+ </plist>
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # Same as Echoes.app's launcher, but exports ECHOES_EMBED=1 so panes
3
+ # come up running rubish in-process via the per-pane helper instead
4
+ # of the legacy PTY-spawn-a-shell mode. See Echoes.app's launcher for
5
+ # why we force native arch on Apple Silicon and how the paths /
6
+ # Ruby interpreter are resolved.
7
+
8
+ set -e
9
+
10
+ SCRIPT="${BASH_SOURCE[0]}"
11
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
12
+ ECHOES_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
13
+ ECHOES_LIB="$ECHOES_ROOT/lib"
14
+ ECHOES_BIN="$ECHOES_ROOT/exe/echoes"
15
+
16
+ if [ -z "${RUBY:-}" ]; then
17
+ for cand in \
18
+ "$HOME/.rbenv/shims/ruby" \
19
+ /opt/homebrew/bin/ruby \
20
+ /usr/local/bin/ruby \
21
+ /usr/bin/ruby
22
+ do
23
+ if [ -x "$cand" ]; then
24
+ RUBY="$cand"
25
+ break
26
+ fi
27
+ done
28
+ fi
29
+ RUBY="${RUBY:-$(command -v ruby || true)}"
30
+ if [ -z "$RUBY" ] || [ ! -x "$RUBY" ]; then
31
+ echo "Echoes: no Ruby interpreter found. Set \$RUBY or install one." >&2
32
+ exit 127
33
+ fi
34
+
35
+ export ECHOES_EMBED=1
36
+
37
+ if [ "$(/usr/sbin/sysctl -in hw.optional.arm64)" = "1" ]; then
38
+ exec /usr/bin/arch -arm64 "$RUBY" -I"$ECHOES_LIB" "$ECHOES_BIN"
39
+ else
40
+ exec "$RUBY" -I"$ECHOES_LIB" "$ECHOES_BIN"
41
+ fi
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Akira Matsuda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # Echoes
2
+
3
+ A pure-Ruby AppKit-based macOS terminal emulator. Echoes aims to be a
4
+ well-integrated host for Ruby tooling — embedding [rubish] (a Ruby
5
+ shell) and [rvim] (a Ruby vim) as first-class panes, with native
6
+ prompt rendering, structured completions, and a private OSC namespace
7
+ that lets in-pane Ruby tools drive UI features (gradient backgrounds,
8
+ proportional fonts) other terminals can't.
9
+
10
+ [rubish]: https://github.com/amatsuda/rubish
11
+ [rvim]: https://github.com/amatsuda/rvim
12
+
13
+ ## Requirements
14
+
15
+ - macOS (uses AppKit via Fiddle; no Linux/Windows support)
16
+ - Ruby >= 3.2
17
+
18
+ ## Installation
19
+
20
+ ```sh
21
+ gem install echoes
22
+ echoes install
23
+ ```
24
+
25
+ `echoes install` drops thin `Echoes.app` and `EchoesEmbed.app`
26
+ shortcuts into `~/Applications/` so the app shows up in Spotlight,
27
+ Dock, and Cmd-Space. Each shortcut is a one-line wrapper that
28
+ `exec`s into the gem-bundled launcher; re-run `echoes install` after
29
+ each `gem update echoes` to refresh the path. `echoes uninstall`
30
+ removes them.
31
+
32
+ To run from a clone instead:
33
+
34
+ ```sh
35
+ git clone https://github.com/amatsuda/echoes
36
+ cd echoes
37
+ bin/setup
38
+ open Echoes.app # GUI (PTY-spawned shell per pane)
39
+ open EchoesEmbed.app # GUI with rubish embedded per pane
40
+ bundle exec exe/echoes -t # TTY mode
41
+ ```
42
+
43
+ ## What's in the box
44
+
45
+ - **Echoes.app** — terminal mode. Spawns the user's `$SHELL` per
46
+ pane via PTY, like any other terminal emulator.
47
+ - **EchoesEmbed.app** — same window/UI, but each pane runs `rubish`
48
+ in-process via a per-pane helper subprocess. Line editing, prompt
49
+ rendering, and tab completion happen natively in Echoes (no ANSI
50
+ roundtrip); only command output flows over the pty.
51
+ - **Edit File…** (Cmd+Shift+E) — opens an rvim-backed editor pane.
52
+ Insert mode, `:w`, `:q`, search, visual mode, undo — the full vim
53
+ surface. The dialog opens at the active pane's pwd.
54
+
55
+ ## Keyboard shortcuts
56
+
57
+ | Shortcut | Action |
58
+ |---------------------------|-----------------------------------------|
59
+ | Cmd+N | New window |
60
+ | Cmd+T | New tab |
61
+ | Cmd+W | Close tab |
62
+ | Cmd+Shift+W | Close pane |
63
+ | Cmd+Shift+E | Edit file… (rvim pane) |
64
+ | Cmd+D | Split pane right |
65
+ | Cmd+Shift+D | Split pane down |
66
+ | Cmd+] / Cmd+[ | Next / previous pane |
67
+ | Cmd+Shift+] / Cmd+Shift+[ | Next / previous tab |
68
+ | Cmd++ / Cmd+- / Cmd+0 | Bigger / smaller / reset font |
69
+ | Cmd+F | Find |
70
+ | Cmd+G / Cmd+Shift+G | Find next / previous |
71
+ | Cmd+Shift+P | Toggle mouse pointer visibility |
72
+ | Cmd+Shift+C | Toggle copy mode |
73
+ | Cmd+Ctrl+F | Enter / leave full screen |
74
+
75
+ ## OSC extensions
76
+
77
+ Echoes recognizes a private OSC namespace under code `7772`. Other
78
+ terminals ignore unknown OSC codes, so emitters degrade gracefully.
79
+
80
+ ```
81
+ \e]7772;bg-color;#rrggbb\a
82
+ \e]7772;bg-gradient;type=linear:angle=N:colors=#rrggbb,#rrggbb\a
83
+ \e]7772;bg-fill;color=#rrggbb:rect=row1,col1,row2,col2\a
84
+ \e]7772;bg-clear\a
85
+ ```
86
+
87
+ `bg-fill` calls accumulate, so a presentation tool can build up a
88
+ slide layout (header bar, sidebar, accent stripe) on top of a base
89
+ `bg-color` or `bg-gradient`. `bg-clear` wipes both the base layer
90
+ and all fills.
91
+
92
+ OSC 66 is also extended:
93
+
94
+ - `f=Family Name` selects a font family for the multicell glyph.
95
+ Proportional fonts are measured per-glyph at layout time so the
96
+ cells reserved match the actual rendered width — `Hello` in Noto
97
+ Serif at 2× lays out cleanly without overflow or gaps.
98
+ - `h=` (halign) is honored for non-fractional / proportional text:
99
+ the whole string lands in a `s × source_chars` cell block, with
100
+ the renderer's existing center / right-align math applied.
101
+
102
+ A small Ruby helper (`Echoes::Client`) lets in-pane Ruby tools emit
103
+ these without hand-rolling escape sequences:
104
+
105
+ ```ruby
106
+ require 'echoes/client'
107
+
108
+ Echoes::Client.bg_gradient(from: '#1a1a2e', to: '#16213e', angle: 90)
109
+ Echoes::Client.bg_fill('#ff6b35', row1: 0, col1: 0, row2: 2, col2: 79)
110
+ Echoes::Client.styled_text("Title", scale: 3, family: "Helvetica Neue")
111
+ Echoes::Client.bg_clear
112
+ ```
113
+
114
+ ## Development
115
+
116
+ ```sh
117
+ bin/setup
118
+ bundle exec rake test # run all tests
119
+ bundle exec exe/echoes # launch from the working tree
120
+ bin/console # irb with the gem loaded
121
+ ```
122
+
123
+ `rake app` syncs `CFBundleVersion` in both bundles' `Info.plist`
124
+ files with `Echoes::VERSION` (run after a version bump).
125
+
126
+ ## Contributing
127
+
128
+ Bug reports and pull requests welcome at
129
+ <https://github.com/amatsuda/echoes>.
130
+
131
+ ## License
132
+
133
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
13
+
14
+ desc "Sync .app Info.plist CFBundleVersion with Echoes::VERSION"
15
+ task :app do
16
+ # The .app bundles (Echoes.app, EchoesEmbed.app) are committed
17
+ # to the repo, including their MacOS/ launcher scripts which
18
+ # locate `lib/` and `exe/echoes` from the script's own path —
19
+ # no per-machine rewrite needed. The one thing that drifts on a
20
+ # version bump is each bundle's Info.plist CFBundleVersion. This
21
+ # task patches that line in place; everything else (launchers,
22
+ # bundle id, package type) is left alone so any hand-edits the
23
+ # bundles have picked up survive.
24
+ require_relative "lib/echoes/version"
25
+ version = Echoes::VERSION
26
+
27
+ plists = %w[Echoes.app/Contents/Info.plist EchoesEmbed.app/Contents/Info.plist]
28
+ plists.each do |path|
29
+ unless File.exist?(path)
30
+ warn "skipping #{path}: not found"
31
+ next
32
+ end
33
+ text = File.read(path)
34
+ new_text = text.sub(
35
+ %r{(<key>CFBundleVersion</key>\s*<string>)[^<]*(</string>)},
36
+ "\\1#{version}\\2"
37
+ )
38
+ if text == new_text
39
+ puts "#{path}: already at #{version}"
40
+ else
41
+ File.write(path, new_text)
42
+ puts "#{path}: CFBundleVersion → #{version}"
43
+ end
44
+ end
45
+ end
data/exe/echoes ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ case ARGV.first
5
+ when 'install', 'uninstall'
6
+ require 'echoes/installer'
7
+ Echoes::Installer.public_send(ARGV.shift)
8
+ else
9
+ require 'echoes'
10
+ if ARGV.delete('--tty') || ARGV.delete('-t')
11
+ Echoes::Terminal.new.run
12
+ else
13
+ Echoes::GUI.new.run
14
+ end
15
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class Cell
5
+ attr_accessor :char, :fg, :bg, :bold, :italic, :underline, :underline_color, :inverse, :faint, :strikethrough, :blink, :concealed, :width, :multicell, :hyperlink
6
+
7
+ def initialize(char = " ", fg: nil, bg: nil, bold: false, underline: false, inverse: false, width: 1)
8
+ @char = char
9
+ @fg = fg
10
+ @bg = bg
11
+ @bold = bold
12
+ @italic = false
13
+ @underline = underline
14
+ @inverse = inverse
15
+ @faint = false
16
+ @strikethrough = false
17
+ @width = width
18
+ end
19
+
20
+ def reset!
21
+ @char = " "
22
+ @fg = nil
23
+ @bg = nil
24
+ @bold = false
25
+ @italic = false
26
+ @underline = false
27
+ @inverse = false
28
+ @faint = false
29
+ @strikethrough = false
30
+ @blink = false
31
+ @concealed = false
32
+ @width = 1
33
+ @multicell = nil
34
+ @hyperlink = nil
35
+ @underline_color = nil
36
+ end
37
+
38
+ def copy_from(other)
39
+ @char = other.char
40
+ @fg = other.fg
41
+ @bg = other.bg
42
+ @bold = other.bold
43
+ @italic = other.italic
44
+ @underline = other.underline
45
+ @underline_color = other.underline_color
46
+ @inverse = other.inverse
47
+ @faint = other.faint
48
+ @strikethrough = other.strikethrough
49
+ @blink = other.blink
50
+ @concealed = other.concealed
51
+ @hyperlink = other.hyperlink
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ # Helpers that emit Echoes-private OSC sequences for tools running
5
+ # inside an Echoes pane (e.g. a Ruby presentation tool that wants
6
+ # Keynote-style gradient slide backgrounds). Other terminals ignore
7
+ # the OSC code, so emitters degrade gracefully.
8
+ #
9
+ # Example:
10
+ # Echoes::Client.bg_gradient(from: '#1a1a2e', to: '#16213e', angle: 90)
11
+ # # ...later, restore the solid background:
12
+ # Echoes::Client.bg_clear
13
+ module Client
14
+ OSC = "\e]7772"
15
+ BEL = "\a"
16
+
17
+ module_function
18
+
19
+ # Paint a linear gradient as the pane's background. `angle` is in
20
+ # degrees (0 = left→right, 90 = bottom→top, matches NSGradient).
21
+ # Pass an array of hex strings via `colors:` for endpoints beyond
22
+ # two (the renderer currently uses first/last only).
23
+ def bg_gradient(from: nil, to: nil, colors: nil, angle: 0, type: :linear, io: $stdout)
24
+ colors ||= [from, to]
25
+ colors = colors.compact.map(&:to_s)
26
+ raise ArgumentError, 'need at least 2 colors' if colors.size < 2
27
+
28
+ args = "type=#{type}:angle=#{angle}:colors=#{colors.join(',')}"
29
+ io.write("#{OSC};bg-gradient;#{args}#{BEL}")
30
+ io.flush if io.respond_to?(:flush)
31
+ nil
32
+ end
33
+
34
+ # Paint the pane's background with a single solid color. The
35
+ # color paints beneath cell content, so cells with their own
36
+ # bg color (selection, themed cells, etc.) still occlude
37
+ # correctly. Pair with bg_clear to revert.
38
+ #
39
+ # Example:
40
+ # Echoes::Client.bg_color('#1a1a2e')
41
+ def bg_color(color, io: $stdout)
42
+ io.write("#{OSC};bg-color;#{color}#{BEL}")
43
+ io.flush if io.respond_to?(:flush)
44
+ nil
45
+ end
46
+
47
+ # Paint a rectangular region of the pane on top of the base
48
+ # background. Calls accumulate — emit several to build up a
49
+ # layout (header/footer bars, sidebars, accent stripes, etc.).
50
+ # `bg_clear` wipes the whole list along with the base background.
51
+ #
52
+ # Coordinates are 0-indexed cell positions, inclusive on both
53
+ # ends — i.e. row1=0, col1=0, row2=2, col2=9 paints a 3x10 block.
54
+ #
55
+ # Example:
56
+ # Echoes::Client.bg_fill('#222', row1: 0, col1: 0, row2: 0, col2: 79) # status bar
57
+ # Echoes::Client.bg_fill('#3a3', row1: 24, col1: 0, row2: 24, col2: 79) # footer
58
+ def bg_fill(color, row1:, col1:, row2:, col2:, io: $stdout)
59
+ args = "color=#{color}:rect=#{row1},#{col1},#{row2},#{col2}"
60
+ io.write("#{OSC};bg-fill;#{args}#{BEL}")
61
+ io.flush if io.respond_to?(:flush)
62
+ nil
63
+ end
64
+
65
+ # Drop any pane background override (bg-color/bg-gradient) AND
66
+ # all bg-fill overlays. Safe to call when nothing is set.
67
+ def bg_clear(io: $stdout)
68
+ io.write("#{OSC};bg-clear#{BEL}")
69
+ io.flush if io.respond_to?(:flush)
70
+ nil
71
+ end
72
+
73
+ # Emit text via OSC 66 (multicell), with optional cell-scale,
74
+ # sub-cell fraction, vertical/horizontal alignment, and font
75
+ # family. `family:` is an Echoes-specific extension other
76
+ # terminals ignore. On unknown families, Echoes falls back to the
77
+ # monospaced system font.
78
+ #
79
+ # Examples:
80
+ # Echoes::Client.styled_text("Title", scale: 3, family: "Helvetica Neue")
81
+ # Echoes::Client.styled_text("• item", scale: 1, family: "Menlo")
82
+ def styled_text(text, scale: 1, width: nil, frac_n: nil, frac_d: nil,
83
+ valign: nil, halign: nil, family: nil, io: $stdout)
84
+ meta = +"s=#{scale}"
85
+ meta << ":w=#{width}" if width
86
+ meta << ":n=#{frac_n}" if frac_n
87
+ meta << ":d=#{frac_d}" if frac_d
88
+ meta << ":v=#{valign}" if valign
89
+ meta << ":h=#{halign}" if halign
90
+ meta << ":f=#{family}" if family
91
+ io.write("\e]66;#{meta};#{text}\a")
92
+ io.flush if io.respond_to?(:flush)
93
+ nil
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class Configuration
5
+ def initialize
6
+ @font_size = 14.0
7
+ @rows = 24
8
+ @cols = 80
9
+ @shell = ENV['SHELL'] || '/bin/bash'
10
+ @scrollback_limit = 1000
11
+ @foreground = [0.9, 0.9, 0.9]
12
+ @background = [0.0, 0.0, 0.0]
13
+ @cursor_color = [0.7, 0.7, 0.7, 0.5]
14
+ @font_family = nil
15
+ @window_title = 'Echoes'
16
+ @tab_position = :top
17
+ @color_palette = nil
18
+ @term = 'xterm-256color'
19
+ @word_separators = ' @*.:/\\()"\'-:,.;<>~!#$%^&*|+=[]{}~?│'
20
+ @selection_color = [0.2, 0.4, 0.7]
21
+ @pane_divider_color = [0.4, 0.4, 0.4]
22
+ @active_pane_border_color = [0.3, 0.5, 0.8]
23
+ @copy_mode_cursor_color = [0.8, 0.7, 0.2]
24
+ end
25
+
26
+ def font_family(val = nil)
27
+ val ? @font_family = val : @font_family
28
+ end
29
+
30
+ def font_size(val = nil)
31
+ val ? @font_size = val.to_f : @font_size
32
+ end
33
+
34
+ def rows(val = nil)
35
+ val ? @rows = val.to_i : @rows
36
+ end
37
+
38
+ def cols(val = nil)
39
+ val ? @cols = val.to_i : @cols
40
+ end
41
+
42
+ def shell(val = nil)
43
+ val ? @shell = val : @shell
44
+ end
45
+
46
+ def scrollback_limit(val = nil)
47
+ val ? @scrollback_limit = val.to_i : @scrollback_limit
48
+ end
49
+
50
+ def foreground(*args)
51
+ args.empty? ? @foreground : @foreground = parse_color(args)
52
+ end
53
+
54
+ def background(*args)
55
+ args.empty? ? @background : @background = parse_color(args)
56
+ end
57
+
58
+ def cursor_color(*args)
59
+ args.empty? ? @cursor_color : @cursor_color = parse_color(args)
60
+ end
61
+
62
+ def window_title(val = nil)
63
+ val ? @window_title = val : @window_title
64
+ end
65
+
66
+ def tab_position(val = nil)
67
+ val ? @tab_position = val.to_sym : @tab_position
68
+ end
69
+
70
+ def term(val = nil)
71
+ val ? @term = val : @term
72
+ end
73
+
74
+ def word_separators(val = nil)
75
+ val ? @word_separators = val : @word_separators
76
+ end
77
+
78
+ def selection_color(*args)
79
+ args.empty? ? @selection_color : @selection_color = parse_color(args)
80
+ end
81
+
82
+ def pane_divider_color(*args)
83
+ args.empty? ? @pane_divider_color : @pane_divider_color = parse_color(args)
84
+ end
85
+
86
+ def active_pane_border_color(*args)
87
+ args.empty? ? @active_pane_border_color : @active_pane_border_color = parse_color(args)
88
+ end
89
+
90
+ def copy_mode_cursor_color(*args)
91
+ args.empty? ? @copy_mode_cursor_color : @copy_mode_cursor_color = parse_color(args)
92
+ end
93
+
94
+ def color_palette(val = nil)
95
+ if val
96
+ @color_palette = val.map { |c| c.is_a?(String) ? parse_color([c]) : c.map(&:to_f) }
97
+ else
98
+ @color_palette
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def parse_color(args)
105
+ if args.size == 1 && args[0].is_a?(String)
106
+ hex = args[0].delete_prefix('#')
107
+ r = hex[0, 2].to_i(16) / 255.0
108
+ g = hex[2, 2].to_i(16) / 255.0
109
+ b = hex[4, 2].to_i(16) / 255.0
110
+ if hex.size == 8
111
+ a = hex[6, 2].to_i(16) / 255.0
112
+ [r, g, b, a]
113
+ else
114
+ [r, g, b]
115
+ end
116
+ else
117
+ args.map(&:to_f)
118
+ end
119
+ end
120
+ end
121
+
122
+ CONFIG_PATH = File.join(Dir.home, '.config', 'echoes', 'echoes.conf')
123
+
124
+ def self.config
125
+ @config ||= Configuration.new
126
+ end
127
+
128
+ def self.load_config
129
+ if File.exist?(CONFIG_PATH)
130
+ config.instance_eval(File.read(CONFIG_PATH), CONFIG_PATH)
131
+ end
132
+ rescue SyntaxError, StandardError => e
133
+ warn "echoes: error loading #{CONFIG_PATH}: #{e.message}"
134
+ end
135
+ end