tui_tui 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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +132 -0
  5. data/Rakefile +8 -0
  6. data/examples/clock.rb +112 -0
  7. data/examples/counter.rb +48 -0
  8. data/examples/csv_viewer.rb +233 -0
  9. data/examples/file_browser.rb +665 -0
  10. data/examples/form.rb +633 -0
  11. data/examples/life.rb +144 -0
  12. data/examples/paint.rb +246 -0
  13. data/examples/todo.rb +250 -0
  14. data/examples/widgets.rb +101 -0
  15. data/lib/tui_tui/ansi.rb +34 -0
  16. data/lib/tui_tui/canvas.rb +187 -0
  17. data/lib/tui_tui/canvas_compositor.rb +45 -0
  18. data/lib/tui_tui/cell.rb +11 -0
  19. data/lib/tui_tui/color_depth.rb +39 -0
  20. data/lib/tui_tui/confirm.rb +74 -0
  21. data/lib/tui_tui/display_text.rb +73 -0
  22. data/lib/tui_tui/event.rb +10 -0
  23. data/lib/tui_tui/event_stream.rb +39 -0
  24. data/lib/tui_tui/focus_ring.rb +25 -0
  25. data/lib/tui_tui/fuzzy.rb +56 -0
  26. data/lib/tui_tui/help.rb +44 -0
  27. data/lib/tui_tui/key_code.rb +9 -0
  28. data/lib/tui_tui/key_intent.rb +29 -0
  29. data/lib/tui_tui/key_reader.rb +175 -0
  30. data/lib/tui_tui/line.rb +59 -0
  31. data/lib/tui_tui/list.rb +45 -0
  32. data/lib/tui_tui/modal.rb +30 -0
  33. data/lib/tui_tui/pager.rb +94 -0
  34. data/lib/tui_tui/palette.rb +49 -0
  35. data/lib/tui_tui/prompt.rb +111 -0
  36. data/lib/tui_tui/rect.rb +48 -0
  37. data/lib/tui_tui/runtime.rb +53 -0
  38. data/lib/tui_tui/screen.rb +85 -0
  39. data/lib/tui_tui/scroll_list.rb +57 -0
  40. data/lib/tui_tui/scrollbar.rb +40 -0
  41. data/lib/tui_tui/select.rb +104 -0
  42. data/lib/tui_tui/size.rb +5 -0
  43. data/lib/tui_tui/span.rb +14 -0
  44. data/lib/tui_tui/status_bar.rb +23 -0
  45. data/lib/tui_tui/style.rb +101 -0
  46. data/lib/tui_tui/terminal_session.rb +65 -0
  47. data/lib/tui_tui/terminal_size.rb +24 -0
  48. data/lib/tui_tui/text_sanitizer.rb +13 -0
  49. data/lib/tui_tui/text_view.rb +52 -0
  50. data/lib/tui_tui/theme.rb +127 -0
  51. data/lib/tui_tui/toast.rb +82 -0
  52. data/lib/tui_tui/version.rb +5 -0
  53. data/lib/tui_tui/width.rb +101 -0
  54. data/lib/tui_tui.rb +51 -0
  55. metadata +98 -0
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+
5
+ module TuiTui
6
+ # Semantic Style roles shared by the built-in widgets.
7
+ # Themes combine a background surface with an accent hue.
8
+ Theme = Data.define(
9
+ # box borders / dividers
10
+ :frame,
11
+ :title,
12
+ :text,
13
+ # de-emphasized text (descriptions, body)
14
+ :muted,
15
+ # highlighted detail (e.g. key names)
16
+ :accent,
17
+ # the focused/selected item or button
18
+ :selection,
19
+ # a selected item in an unfocused pane (de-emphasized)
20
+ :selection_dim,
21
+ # status / footer bar background
22
+ :bar,
23
+ :cursor,
24
+ :scroll_track,
25
+ :scroll_thumb
26
+ )
27
+
28
+ class Theme
29
+ # Background-dependent neutral roles.
30
+ SURFACES = {
31
+ dark: {
32
+ text: Style.new,
33
+ muted: Style.new(fg: 245),
34
+ bar: Style.new(fg: 252, bg: 238),
35
+ selection_dim: Style.new(fg: 247, bg: 238)
36
+ },
37
+ light: {
38
+ text: Style.new,
39
+ muted: Style.new(fg: 240),
40
+ bar: Style.new(fg: 16, bg: 252),
41
+ selection_dim: Style.new(fg: 240, bg: 252)
42
+ }
43
+ }.freeze
44
+
45
+ # Accent roles per hue and background.
46
+ ACCENTS = {
47
+ cool: {
48
+ dark: {line: 66, title: 109, accent: 73, sel: [231, 60]},
49
+ light: {line: 30, title: 25, accent: 30, sel: [16, 152]}
50
+ },
51
+ warm: {
52
+ dark: {line: 95, title: 137, accent: 173, sel: [231, 95]},
53
+ light: {line: 95, title: 94, accent: 130, sel: [16, 180]}
54
+ },
55
+ mono: {
56
+ dark: {line: 240, title: 252, accent: 252, sel: [16, 250]},
57
+ light: {line: 240, title: 236, accent: 236, sel: [16, 250]}
58
+ }
59
+ }.freeze
60
+
61
+ # COLORFGBG background values meaning "light".
62
+ LIGHT_FGBG = %w[7 15].freeze
63
+
64
+ def self.bold(fg) = Style.new(fg: fg, attrs: [:bold])
65
+
66
+ # Build the palette for a (background, hue) pair.
67
+ def self.compose(background, hue)
68
+ surface = SURFACES.fetch(background)
69
+ a = ACCENTS.fetch(hue).fetch(background)
70
+ selection = Style.new(fg: a[:sel][0], bg: a[:sel][1])
71
+ new(
72
+ frame: Style.new(fg: a[:line]),
73
+ title: bold(a[:title]),
74
+ text: surface[:text],
75
+ muted: surface[:muted],
76
+ accent: bold(a[:accent]),
77
+ selection: selection,
78
+ selection_dim: surface[:selection_dim],
79
+ bar: surface[:bar],
80
+ cursor: selection,
81
+ scroll_track: Style.new(fg: a[:line]),
82
+ scroll_thumb: Style.new(bg: a[:sel][1])
83
+ )
84
+ end
85
+
86
+ # Shared palettes for every surface/hue pair.
87
+ TABLE = SURFACES.keys.product(ACCENTS.keys).to_h { |bg, hue| [[bg, hue], compose(bg, hue)] }.freeze
88
+
89
+ def self.build(background: :dark, hue: :cool) = TABLE.fetch([background, hue])
90
+
91
+ # Best-effort terminal background (:light/:dark).
92
+ def self.detect_background(env: ENV)
93
+ case env["TUITUI_BACKGROUND"]&.downcase
94
+ when "light"
95
+ :light
96
+ when "dark"
97
+ :dark
98
+ else
99
+ bg = env["COLORFGBG"]&.split(";")&.last
100
+ bg && LIGHT_FGBG.include?(bg) ? :light : :dark
101
+ end
102
+ end
103
+
104
+ # The hue palette tuned for the detected background.
105
+ def self.auto(hue: :cool, env: ENV) = build(background: detect_background(env: env), hue: hue)
106
+
107
+ # Fetch a preset by name (Symbol/String); unknown names fall back to DEFAULT.
108
+ def self.named(name) = PRESETS.fetch(name&.to_sym, DEFAULT)
109
+ end
110
+
111
+ Theme::DARK = Theme.build(background: :dark, hue: :cool)
112
+ Theme::LIGHT = Theme.build(background: :light, hue: :cool)
113
+ Theme::DEFAULT = Theme::DARK
114
+ Theme::WARM = Theme.build(background: :dark, hue: :warm)
115
+ Theme::MONO = Theme.build(background: :dark, hue: :mono)
116
+
117
+ class Theme
118
+ PRESETS = {
119
+ default: DEFAULT,
120
+ cool: DEFAULT,
121
+ dark: DARK,
122
+ light: LIGHT,
123
+ warm: WARM,
124
+ mono: MONO
125
+ }.freeze
126
+ end
127
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_text"
4
+ require_relative "style"
5
+
6
+ module TuiTui
7
+ # A transient notification overlay.
8
+ # The clock is injectable so expiry is testable without sleeping.
9
+ class Toast
10
+ DEFAULT_SECONDS = 2.0
11
+ DEFAULT_STYLE = Style.new(attrs: [:reverse])
12
+ DEFAULT_POSITION = :bottom_center
13
+ POSITIONS = {
14
+ top_left: [:top, :left],
15
+ top_center: [:top, :center],
16
+ top_right: [:top, :right],
17
+ middle_left: [:middle, :left],
18
+ middle_center: [:middle, :center],
19
+ middle_right: [:middle, :right],
20
+ bottom_left: [:bottom, :left],
21
+ bottom_center: [:bottom, :center],
22
+ bottom_right: [:bottom, :right],
23
+ center: [:middle, :center]
24
+ }.freeze
25
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
26
+
27
+ def initialize(message, seconds: DEFAULT_SECONDS, position: DEFAULT_POSITION, clock: MONOTONIC)
28
+ @message = DisplayText.new(message)
29
+ @position = position
30
+ @clock = clock
31
+ @expires_at = clock.call + seconds
32
+ validate_position!
33
+ end
34
+
35
+ def expired? = @clock.call >= @expires_at
36
+
37
+ def draw(canvas, size, style: DEFAULT_STYLE, position: @position)
38
+ return canvas if expired?
39
+
40
+ label = DisplayText.new(" #{@message} ").truncate(size.cols)
41
+ vertical, horizontal = position_parts(position)
42
+ row = row_for(size, vertical)
43
+ col = col_for(size, label.width, horizontal)
44
+ canvas.text(row, col, label, style)
45
+ canvas
46
+ end
47
+
48
+ private
49
+
50
+ def validate_position!
51
+ position_parts(@position)
52
+ end
53
+
54
+ def position_parts(position)
55
+ POSITIONS.fetch(position) do
56
+ raise ArgumentError, "unknown toast position: #{position.inspect}"
57
+ end
58
+ end
59
+
60
+ def row_for(size, vertical)
61
+ case vertical
62
+ when :top
63
+ 1
64
+ when :middle
65
+ ((size.rows + 1) / 2).clamp(1, size.rows)
66
+ when :bottom
67
+ [size.rows - 2, 1].max
68
+ end
69
+ end
70
+
71
+ def col_for(size, width, horizontal)
72
+ case horizontal
73
+ when :left
74
+ 1
75
+ when :center
76
+ [((size.cols - width) / 2) + 1, 1].max
77
+ when :right
78
+ [size.cols - width + 1, 1].max
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Terminal column width for the Unicode ranges this renderer needs.
5
+ # Wide/fullwidth clusters are 2, combining/control clusters are 0.
6
+ module Width
7
+ WIDE = [
8
+ # Hangul Jamo
9
+ [0x1100, 0x115F],
10
+ # CJK radicals, Kangxi, CJK symbols & punctuation
11
+ [0x2E80, 0x303E],
12
+ # Hiragana, Katakana, CJK symbols, enclosed CJK, ...
13
+ [0x3041, 0x33FF],
14
+ # CJK Unified Ext A
15
+ [0x3400, 0x4DBF],
16
+ # CJK Unified Ideographs
17
+ [0x4E00, 0x9FFF],
18
+ # Yi
19
+ [0xA000, 0xA4CF],
20
+ # Hangul Jamo Ext A
21
+ [0xA960, 0xA97F],
22
+ # Hangul Syllables
23
+ [0xAC00, 0xD7A3],
24
+ # CJK Compatibility Ideographs
25
+ [0xF900, 0xFAFF],
26
+ # Vertical forms
27
+ [0xFE10, 0xFE19],
28
+ # CJK Compatibility / small forms
29
+ [0xFE30, 0xFE6F],
30
+ # Fullwidth forms
31
+ [0xFF00, 0xFF60],
32
+ # Fullwidth signs
33
+ [0xFFE0, 0xFFE6],
34
+ # Kana supplement / extended
35
+ [0x1B000, 0x1B16F],
36
+ # Regional indicator symbols (flag letters)
37
+ [0x1F1E6, 0x1F1FF],
38
+ # Enclosed ideographic supplement
39
+ [0x1F200, 0x1F251],
40
+ # Misc symbols & pictographs + emoticons
41
+ [0x1F300, 0x1F64F],
42
+ # Transport & map symbols
43
+ [0x1F680, 0x1F6FF],
44
+ # Supplemental symbols & pictographs
45
+ [0x1F900, 0x1F9FF],
46
+ # Symbols & pictographs extended-A
47
+ [0x1FA70, 0x1FAFF],
48
+ # CJK Unified Ext B and beyond
49
+ [0x20000, 0x3FFFD]
50
+ ].freeze
51
+
52
+ ZERO = [
53
+ # Combining diacritical marks
54
+ [0x0300, 0x036F],
55
+ [0x0483, 0x0489],
56
+ [0x0591, 0x05BD],
57
+ [0x0610, 0x061A],
58
+ [0x064B, 0x065F],
59
+ [0x06D6, 0x06DC],
60
+ # Zero-width space / joiners / marks
61
+ [0x200B, 0x200F],
62
+ # Variation selectors
63
+ [0xFE00, 0xFE0F],
64
+ # Zero-width no-break space (BOM)
65
+ [0xFEFF, 0xFEFF],
66
+ # Emoji skin-tone modifiers (combine onto the base)
67
+ [0x1F3FB, 0x1F3FF]
68
+ ].freeze
69
+
70
+ class << self
71
+
72
+ def cluster(grapheme)
73
+ # A cluster's base codepoint determines its terminal width.
74
+ base = grapheme[0]
75
+ return 1 if control?(base.ord)
76
+
77
+ char(base)
78
+ end
79
+
80
+ def char(char)
81
+ cp = char.ord
82
+ return 0 if control?(cp)
83
+ return 0 if in?(cp, ZERO)
84
+
85
+ in?(cp, WIDE) ? 2 : 1
86
+ end
87
+
88
+ def control?(cp)
89
+ cp < 0x20 || cp == 0x7F || cp.between?(0x80, 0x9F)
90
+ end
91
+
92
+ private
93
+
94
+ def in?(cp, ranges)
95
+ # Ranges are sorted and non-overlapping, so one binary search is enough.
96
+ index = ranges.bsearch_index { |_lo, hi| hi >= cp }
97
+ !index.nil? && ranges[index][0] <= cp
98
+ end
99
+ end
100
+ end
101
+ end
data/lib/tui_tui.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tui_tui/version"
4
+
5
+ require_relative "tui_tui/width"
6
+ require_relative "tui_tui/text_sanitizer"
7
+ require_relative "tui_tui/display_text"
8
+ require_relative "tui_tui/span"
9
+ require_relative "tui_tui/line"
10
+ require_relative "tui_tui/ansi"
11
+ require_relative "tui_tui/color_depth"
12
+ require_relative "tui_tui/palette"
13
+ require_relative "tui_tui/style"
14
+ require_relative "tui_tui/theme"
15
+ require_relative "tui_tui/size"
16
+ require_relative "tui_tui/rect"
17
+ require_relative "tui_tui/cell"
18
+ require_relative "tui_tui/canvas"
19
+ require_relative "tui_tui/canvas_compositor"
20
+ require_relative "tui_tui/event"
21
+ require_relative "tui_tui/key_code"
22
+ require_relative "tui_tui/key_reader"
23
+ require_relative "tui_tui/key_intent"
24
+ require_relative "tui_tui/event_stream"
25
+ require_relative "tui_tui/terminal_session"
26
+ require_relative "tui_tui/scroll_list"
27
+ require_relative "tui_tui/list"
28
+ require_relative "tui_tui/text_view"
29
+ require_relative "tui_tui/scrollbar"
30
+ require_relative "tui_tui/status_bar"
31
+ require_relative "tui_tui/toast"
32
+ require_relative "tui_tui/focus_ring"
33
+ require_relative "tui_tui/fuzzy"
34
+ require_relative "tui_tui/modal"
35
+ require_relative "tui_tui/confirm"
36
+ require_relative "tui_tui/select"
37
+ require_relative "tui_tui/help"
38
+ require_relative "tui_tui/prompt"
39
+ require_relative "tui_tui/pager"
40
+ require_relative "tui_tui/screen"
41
+ require_relative "tui_tui/runtime"
42
+
43
+ # A tiny, owned TUI runtime for rendering in modern graphical terminals. Its only
44
+ # dependency is `io/console` (a default gem, used by the Screen driver); the pure
45
+ # pieces — width, color, style, layout — need nothing.
46
+ #
47
+ # The framework is application-agnostic. Build an app object responding to
48
+ # `view(size) -> Canvas` and `update(event) -> app | :quit`, then drive it with
49
+ # `TuiTui::Runtime`.
50
+ module TuiTui
51
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tui_tui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'TuiTui is a small terminal-UI framework: a width-aware canvas, a diffing
13
+ renderer, an Elm-style runtime loop, and composable widgets (modals, lists, prompts).
14
+ Its only dependency is io/console.'
15
+ email:
16
+ - takahashimm@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - ".github/workflows/ci.yml"
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - examples/clock.rb
26
+ - examples/counter.rb
27
+ - examples/csv_viewer.rb
28
+ - examples/file_browser.rb
29
+ - examples/form.rb
30
+ - examples/life.rb
31
+ - examples/paint.rb
32
+ - examples/todo.rb
33
+ - examples/widgets.rb
34
+ - lib/tui_tui.rb
35
+ - lib/tui_tui/ansi.rb
36
+ - lib/tui_tui/canvas.rb
37
+ - lib/tui_tui/canvas_compositor.rb
38
+ - lib/tui_tui/cell.rb
39
+ - lib/tui_tui/color_depth.rb
40
+ - lib/tui_tui/confirm.rb
41
+ - lib/tui_tui/display_text.rb
42
+ - lib/tui_tui/event.rb
43
+ - lib/tui_tui/event_stream.rb
44
+ - lib/tui_tui/focus_ring.rb
45
+ - lib/tui_tui/fuzzy.rb
46
+ - lib/tui_tui/help.rb
47
+ - lib/tui_tui/key_code.rb
48
+ - lib/tui_tui/key_intent.rb
49
+ - lib/tui_tui/key_reader.rb
50
+ - lib/tui_tui/line.rb
51
+ - lib/tui_tui/list.rb
52
+ - lib/tui_tui/modal.rb
53
+ - lib/tui_tui/pager.rb
54
+ - lib/tui_tui/palette.rb
55
+ - lib/tui_tui/prompt.rb
56
+ - lib/tui_tui/rect.rb
57
+ - lib/tui_tui/runtime.rb
58
+ - lib/tui_tui/screen.rb
59
+ - lib/tui_tui/scroll_list.rb
60
+ - lib/tui_tui/scrollbar.rb
61
+ - lib/tui_tui/select.rb
62
+ - lib/tui_tui/size.rb
63
+ - lib/tui_tui/span.rb
64
+ - lib/tui_tui/status_bar.rb
65
+ - lib/tui_tui/style.rb
66
+ - lib/tui_tui/terminal_session.rb
67
+ - lib/tui_tui/terminal_size.rb
68
+ - lib/tui_tui/text_sanitizer.rb
69
+ - lib/tui_tui/text_view.rb
70
+ - lib/tui_tui/theme.rb
71
+ - lib/tui_tui/toast.rb
72
+ - lib/tui_tui/version.rb
73
+ - lib/tui_tui/width.rb
74
+ homepage: https://github.com/takahashim/tui_tui
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ allowed_push_host: https://rubygems.org
79
+ homepage_uri: https://github.com/takahashim/tui_tui
80
+ source_code_uri: https://github.com/takahashim/tui_tui
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 3.2.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 4.0.10
96
+ specification_version: 4
97
+ summary: A tiny, dependency-free TUI runtime for modern terminals.
98
+ test_files: []