potty 0.0.1

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +31 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +270 -0
  5. data/bin/potty_demo +128 -0
  6. data/examples/test_view.rb +87 -0
  7. data/lib/potty/animator.rb +127 -0
  8. data/lib/potty/application.rb +136 -0
  9. data/lib/potty/border.rb +51 -0
  10. data/lib/potty/events.rb +46 -0
  11. data/lib/potty/keys.rb +71 -0
  12. data/lib/potty/layout.rb +47 -0
  13. data/lib/potty/sprite.rb +49 -0
  14. data/lib/potty/sprites/sample.rb +36 -0
  15. data/lib/potty/style.rb +14 -0
  16. data/lib/potty/surface.rb +46 -0
  17. data/lib/potty/surfaces/curses_surface.rb +114 -0
  18. data/lib/potty/surfaces/inline_surface.rb +148 -0
  19. data/lib/potty/theme.rb +82 -0
  20. data/lib/potty/version.rb +5 -0
  21. data/lib/potty/view.rb +132 -0
  22. data/lib/potty/widgets/base.rb +114 -0
  23. data/lib/potty/widgets/button.rb +52 -0
  24. data/lib/potty/widgets/checkbox_group.rb +101 -0
  25. data/lib/potty/widgets/colored_fields_item.rb +56 -0
  26. data/lib/potty/widgets/container.rb +113 -0
  27. data/lib/potty/widgets/countdown.rb +81 -0
  28. data/lib/potty/widgets/flash_message.rb +69 -0
  29. data/lib/potty/widgets/label.rb +37 -0
  30. data/lib/potty/widgets/list.rb +192 -0
  31. data/lib/potty/widgets/list_item.rb +120 -0
  32. data/lib/potty/widgets/panel.rb +49 -0
  33. data/lib/potty/widgets/progress_bar.rb +55 -0
  34. data/lib/potty/widgets/radio_group.rb +121 -0
  35. data/lib/potty/widgets/spinner.rb +84 -0
  36. data/lib/potty/widgets/status_bar.rb +56 -0
  37. data/lib/potty/widgets/text_input.rb +138 -0
  38. data/lib/potty/widgets/toggle.rb +65 -0
  39. data/lib/potty/window_manager.rb +55 -0
  40. data/lib/potty.rb +35 -0
  41. metadata +112 -0
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+ require_relative 'base'
5
+ require_relative '../keys'
6
+
7
+ module Potty
8
+ module Widgets
9
+ # N mutually exclusive options, one row each. Up/down move the cursor;
10
+ # Space/Enter selects the option under the cursor. Renders "(\u25CF) label"
11
+ # for the chosen option and "(\u25CB) label" for the rest.
12
+ #
13
+ # Note the two distinct positions: the *cursor* (highlight, moved by
14
+ # arrows) and the *selection* (the committed value). They diverge while
15
+ # the user is navigating and reconverge on select.
16
+ class RadioGroup < Base
17
+ attr_accessor :on_change
18
+
19
+ def initialize(app, options: [], selected: nil, on_change: nil)
20
+ super(app)
21
+ @options = normalize(options)
22
+ @on_change = on_change
23
+ @selected = selected.nil? ? @options.first&.fetch(:value) : selected
24
+ @cursor = index_of(@selected) || 0
25
+ end
26
+
27
+ def can_focus?
28
+ true
29
+ end
30
+
31
+ def options
32
+ @options
33
+ end
34
+
35
+ def options=(opts)
36
+ @options = normalize(opts)
37
+ @cursor = @cursor.clamp(0, [@options.size - 1, 0].max)
38
+ end
39
+
40
+ def selected
41
+ @selected
42
+ end
43
+
44
+ def selected=(value)
45
+ idx = index_of(value)
46
+ return unless idx
47
+
48
+ @selected = value
49
+ @cursor = idx
50
+ @on_change&.call(@selected)
51
+ emit(:change, @selected)
52
+ end
53
+
54
+ def preferred_height(_width)
55
+ @options.size
56
+ end
57
+
58
+ def handle_key(ch)
59
+ case ch
60
+ when Keys::UP
61
+ move(-1)
62
+ when Keys::DOWN
63
+ move(1)
64
+ when Keys::SPACE, *Keys::ENTERS
65
+ choose(@cursor)
66
+ else
67
+ return false
68
+ end
69
+ true
70
+ end
71
+
72
+ def render(window)
73
+ return unless @visible && @rect
74
+
75
+ @options.each_with_index do |opt, i|
76
+ break if i >= @rect.height
77
+
78
+ marker = opt[:value] == @selected ? "(\u25CF)" : "(\u25CB)"
79
+ on_cursor = @focused && i == @cursor
80
+ attr = on_cursor ? theme.attr(:selected, bold: true) : theme[:normal]
81
+ text = "#{marker} #{opt[:label]}"[0, @rect.width]
82
+
83
+ window.setpos(@rect.y + i, @rect.x)
84
+ window.attron(attr) { window.addstr(text) }
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def normalize(opts)
91
+ (opts || []).map do |o|
92
+ if o.is_a?(Hash)
93
+ { value: o[:value], label: (o[:label] || o[:value]).to_s }
94
+ else
95
+ { value: o, label: o.to_s }
96
+ end
97
+ end
98
+ end
99
+
100
+ def move(delta)
101
+ return if @options.empty?
102
+
103
+ @cursor = (@cursor + delta) % @options.size
104
+ end
105
+
106
+ def choose(idx)
107
+ opt = @options[idx]
108
+ return unless opt
109
+ return if opt[:value] == @selected
110
+
111
+ @selected = opt[:value]
112
+ @on_change&.call(@selected)
113
+ emit(:change, @selected)
114
+ end
115
+
116
+ def index_of(value)
117
+ @options.index { |o| o[:value] == value }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../animator'
5
+ require_relative '../sprites/sample'
6
+
7
+ module Potty
8
+ module Widgets
9
+ # Single-line activity indicator: an animated braille spinner, a live
10
+ # mutable label, and a trailing state. While active the glyph is the
11
+ # spinner; complete!(result) freezes it to a fixed glyph and flips the
12
+ # color. Passive (no focus/input). Tick-driven via an internal Animator.
13
+ #
14
+ # s = Spinner.new(app, label: "daemon - running")
15
+ # s.label = "daemon - surrendering 16 children" # live update
16
+ # s.complete!(:success) # glyph -> checkmark, color -> :success
17
+ class Spinner < Base
18
+ STATE_GLYPHS = { success: "\u2713", failure: "\u2717", cancelled: "\u23F9" }.freeze
19
+ STATE_COLORS = { success: :success, failure: :error, cancelled: :dim }.freeze
20
+
21
+ attr_accessor :label, :prefix
22
+ attr_reader :state, :color
23
+
24
+ def initialize(app, label: '', color: :info, prefix: ' ')
25
+ super(app)
26
+ @label = label
27
+ @color = color
28
+ @prefix = prefix
29
+ @state = :active
30
+ @animator = Animator.new(app)
31
+ @animator << Sprites::Sample.spinner
32
+ end
33
+
34
+ def active?
35
+ @state == :active
36
+ end
37
+
38
+ # Freeze the spinner to a terminal state. Idempotent: only the first
39
+ # call takes effect, so repeated lifecycle events are harmless.
40
+ def complete!(result = :success)
41
+ return self unless active?
42
+
43
+ @state = result
44
+ @color = STATE_COLORS.fetch(result, @color)
45
+ @animator.stop
46
+ emit(:complete, result)
47
+ self
48
+ end
49
+
50
+ def preferred_height(_width)
51
+ 1
52
+ end
53
+
54
+ def tick(now)
55
+ @animator.tick(now) if active?
56
+ end
57
+
58
+ def render(window)
59
+ return unless @visible && @rect
60
+
61
+ glyph = active? ? current_frame : STATE_GLYPHS.fetch(@state, '?')
62
+ text = truncate("#{@prefix}#{glyph} #{@label}", @rect.width)
63
+ window.setpos(@rect.y, @rect.x)
64
+ # theme.style (not theme[]) so we render in colour on either surface —
65
+ # curses resolves the Style to a pair, inline to ANSI SGR.
66
+ window.attron(theme.style(@color)) { window.addstr(text) }
67
+ end
68
+
69
+ private
70
+
71
+ def current_frame
72
+ sprite = @animator.sprite
73
+ sprite ? sprite.frame_lines(@animator.frame_index).first : ' '
74
+ end
75
+
76
+ def truncate(str, width)
77
+ return str if str.length <= width
78
+ return str[0, width] || '' if width < 2
79
+
80
+ "#{str[0, width - 1]}\u2026"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Potty
6
+ module Widgets
7
+ # Status bar at bottom of screen
8
+ class StatusBar < Base
9
+ attr_accessor :left_text, :center_text, :right_text
10
+
11
+ def initialize(app)
12
+ super
13
+ @left_text = ""
14
+ @center_text = ""
15
+ @right_text = ""
16
+ end
17
+
18
+ def preferred_height(width)
19
+ 1
20
+ end
21
+
22
+ def render(window)
23
+ return unless @rect
24
+
25
+ window.setpos(@rect.y, @rect.x)
26
+ window.attron(theme[:status]) do
27
+ # Clear line with background color
28
+ window.addstr(" " * @rect.width)
29
+
30
+ # Left-aligned
31
+ if @left_text && !@left_text.empty?
32
+ window.setpos(@rect.y, @rect.x)
33
+ max_left = @rect.width / 3
34
+ window.addstr(@left_text[0, max_left])
35
+ end
36
+
37
+ # Center-aligned
38
+ if @center_text && !@center_text.empty?
39
+ center_x = @rect.x + (@rect.width - @center_text.length) / 2
40
+ center_x = [center_x, @rect.x].max
41
+ window.setpos(@rect.y, center_x)
42
+ window.addstr(@center_text[0, @rect.width])
43
+ end
44
+
45
+ # Right-aligned
46
+ if @right_text && !@right_text.empty?
47
+ right_x = @rect.x + @rect.width - @right_text.length
48
+ right_x = [right_x, @rect.x].max
49
+ window.setpos(@rect.y, right_x)
50
+ window.addstr(@right_text)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+ require_relative 'base'
5
+ require_relative '../keys'
6
+
7
+ module Potty
8
+ module Widgets
9
+ # Single-line editable text field. Shows a block cursor when focused,
10
+ # a dim placeholder when empty and unfocused, and scrolls horizontally
11
+ # when the text outgrows the field.
12
+ #
13
+ # Emits :change(text) on every edit. ASCII input only for now (matches
14
+ # the rest of the framework); UTF-8 entry would need multibyte getch.
15
+ class TextInput < Base
16
+ attr_reader :text
17
+ attr_accessor :placeholder, :max_length, :on_change
18
+
19
+ def initialize(app, text: '', placeholder: '', max_length: nil, on_change: nil)
20
+ super(app)
21
+ @text = text.dup
22
+ @placeholder = placeholder
23
+ @max_length = max_length
24
+ @on_change = on_change
25
+ @cursor = @text.length
26
+ @scroll = 0
27
+ end
28
+
29
+ def can_focus?
30
+ true
31
+ end
32
+
33
+ def text=(value)
34
+ @text = value.to_s.dup
35
+ @cursor = [@cursor, @text.length].min
36
+ notify_change
37
+ end
38
+
39
+ def preferred_height(_width)
40
+ 1
41
+ end
42
+
43
+ def handle_key(ch)
44
+ case ch
45
+ when Keys::LEFT
46
+ @cursor = [@cursor - 1, 0].max
47
+ when Keys::RIGHT
48
+ @cursor = [@cursor + 1, @text.length].min
49
+ when Keys::HOME, Keys::CTRL_A
50
+ @cursor = 0
51
+ when Keys::END_, Keys::CTRL_E
52
+ @cursor = @text.length
53
+ when Keys::DEL_ASCII, Keys::CTRL_H, Keys::BACKSPACE
54
+ backspace
55
+ when Keys::DELETE, Keys::CTRL_D
56
+ delete_forward
57
+ when Keys::SPACE..(Keys::DEL_ASCII - 1)
58
+ insert(ch.chr)
59
+ else
60
+ return false
61
+ end
62
+ true
63
+ end
64
+
65
+ def render(window)
66
+ return unless @visible && @rect
67
+
68
+ width = @rect.width
69
+ adjust_scroll(width)
70
+
71
+ if @text.empty? && !@focused
72
+ window.setpos(@rect.y, @rect.x)
73
+ window.attron(theme[:dim]) do
74
+ window.addstr(@placeholder.to_s[0, width].to_s.ljust(width))
75
+ end
76
+ return
77
+ end
78
+
79
+ visible = (@text[@scroll, width] || '').ljust(width)
80
+ window.setpos(@rect.y, @rect.x)
81
+ window.attron(theme[:normal]) { window.addstr(visible) }
82
+
83
+ return unless @focused
84
+
85
+ # Block cursor: reverse-video the cell under the caret.
86
+ col = @cursor - @scroll
87
+ return if col.negative? || col >= width
88
+
89
+ char_under = @text[@cursor] || ' '
90
+ window.setpos(@rect.y, @rect.x + col)
91
+ window.attron(theme[:normal] | ::Curses::A_REVERSE) do
92
+ window.addstr(char_under)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def insert(str)
99
+ return if @max_length && @text.length >= @max_length
100
+
101
+ @text.insert(@cursor, str)
102
+ @cursor += str.length
103
+ notify_change
104
+ end
105
+
106
+ def backspace
107
+ return if @cursor.zero?
108
+
109
+ @text.slice!(@cursor - 1)
110
+ @cursor -= 1
111
+ notify_change
112
+ end
113
+
114
+ def delete_forward
115
+ return if @cursor >= @text.length
116
+
117
+ @text.slice!(@cursor)
118
+ notify_change
119
+ end
120
+
121
+ def adjust_scroll(width)
122
+ return if width <= 0
123
+
124
+ @scroll = @cursor - width + 1 if @cursor - @scroll >= width
125
+ @scroll = @cursor if @cursor < @scroll
126
+ @scroll = [@scroll, 0].max
127
+ end
128
+
129
+ def notify_change
130
+ # Hand listeners a snapshot, not the live internal buffer, so a
131
+ # consumer that stores the value doesn't see it mutate underfoot.
132
+ snapshot = @text.dup
133
+ @on_change&.call(snapshot)
134
+ emit(:change, snapshot)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../keys'
5
+
6
+ module Potty
7
+ module Widgets
8
+ # Boolean on/off control. Space (or Enter) flips it when focused.
9
+ # Renders "[\u25CF] label" when on, "[\u25CB] label" when off.
10
+ # Emits :change(value) when toggled.
11
+ class Toggle < Base
12
+ attr_reader :value
13
+ attr_accessor :label, :on_change
14
+
15
+ def initialize(app, label: '', value: false, on_change: nil)
16
+ super(app)
17
+ @label = label
18
+ @value = value
19
+ @on_change = on_change
20
+ end
21
+
22
+ def can_focus?
23
+ true
24
+ end
25
+
26
+ def value=(val)
27
+ val = val ? true : false
28
+ return if val == @value
29
+
30
+ @value = val
31
+ @on_change&.call(@value)
32
+ emit(:change, @value)
33
+ end
34
+
35
+ def toggle
36
+ self.value = !@value
37
+ end
38
+
39
+ def preferred_height(_width)
40
+ 1
41
+ end
42
+
43
+ def handle_key(ch)
44
+ case ch
45
+ when Keys::SPACE, *Keys::ENTERS
46
+ toggle
47
+ true
48
+ else
49
+ false
50
+ end
51
+ end
52
+
53
+ def render(window)
54
+ return unless @visible && @rect
55
+
56
+ knob = @value ? "[\u25CF]" : "[\u25CB]"
57
+ text = "#{knob} #{@label}"[0, @rect.width]
58
+ attr = @focused ? theme.attr(:selected, bold: true) : theme[:normal]
59
+
60
+ window.setpos(@rect.y, @rect.x)
61
+ window.attron(attr) { window.addstr(text) }
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+
5
+ module Potty
6
+ # Manages curses window lifecycle and refresh coordination
7
+ class WindowManager
8
+ attr_reader :stdscr, :max_y, :max_x
9
+
10
+ def initialize
11
+ @windows = {}
12
+ @stdscr = nil
13
+ end
14
+
15
+ # Called during application setup
16
+ def setup(stdscr)
17
+ @stdscr = stdscr
18
+ update_dimensions
19
+ end
20
+
21
+ def update_dimensions
22
+ @max_y, @max_x = @stdscr.maxy, @stdscr.maxx
23
+ end
24
+
25
+ # Create a new window
26
+ def create_window(name, height, width, y, x)
27
+ win = ::Curses::Window.new(height, width, y, x)
28
+ @windows[name] = win
29
+ win
30
+ end
31
+
32
+ # Get existing window
33
+ def get_window(name)
34
+ @windows[name]
35
+ end
36
+
37
+ # Destroy window
38
+ def destroy_window(name)
39
+ @windows[name]&.close
40
+ @windows.delete(name)
41
+ end
42
+
43
+ # Refresh all windows efficiently
44
+ def refresh_all
45
+ @stdscr.noutrefresh
46
+ @windows.values.each(&:noutrefresh)
47
+ ::Curses.doupdate
48
+ end
49
+
50
+ def clear_all
51
+ @stdscr.clear
52
+ @windows.values.each(&:clear)
53
+ end
54
+ end
55
+ end
data/lib/potty.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'potty/version'
4
+ require_relative 'potty/keys'
5
+ require_relative 'potty/events'
6
+ require_relative 'potty/style'
7
+ require_relative 'potty/application'
8
+ require_relative 'potty/theme'
9
+ require_relative 'potty/layout'
10
+ require_relative 'potty/border'
11
+ require_relative 'potty/window_manager'
12
+ require_relative 'potty/view'
13
+ require_relative 'potty/widgets/base'
14
+ require_relative 'potty/widgets/container'
15
+ require_relative 'potty/widgets/panel'
16
+ require_relative 'potty/widgets/list'
17
+ require_relative 'potty/widgets/list_item'
18
+ require_relative 'potty/widgets/colored_fields_item'
19
+ require_relative 'potty/widgets/flash_message'
20
+ require_relative 'potty/widgets/status_bar'
21
+ require_relative 'potty/widgets/progress_bar'
22
+ require_relative 'potty/widgets/label'
23
+ require_relative 'potty/widgets/button'
24
+ require_relative 'potty/widgets/text_input'
25
+ require_relative 'potty/widgets/toggle'
26
+ require_relative 'potty/widgets/radio_group'
27
+ require_relative 'potty/widgets/checkbox_group'
28
+ require_relative 'potty/widgets/countdown'
29
+ require_relative 'potty/widgets/spinner'
30
+ require_relative 'potty/sprite'
31
+ require_relative 'potty/animator'
32
+ require_relative 'potty/sprites/sample'
33
+
34
+ module Potty
35
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: potty
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - TwilightCoders
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: curses
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.12'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.12'
40
+ description: Provides views, widgets, layout, theming, and frame-based animation for
41
+ building curses TUI applications.
42
+ email:
43
+ - info@twilightcoders.net
44
+ executables:
45
+ - potty_demo
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - bin/potty_demo
53
+ - examples/test_view.rb
54
+ - lib/potty.rb
55
+ - lib/potty/animator.rb
56
+ - lib/potty/application.rb
57
+ - lib/potty/border.rb
58
+ - lib/potty/events.rb
59
+ - lib/potty/keys.rb
60
+ - lib/potty/layout.rb
61
+ - lib/potty/sprite.rb
62
+ - lib/potty/sprites/sample.rb
63
+ - lib/potty/style.rb
64
+ - lib/potty/surface.rb
65
+ - lib/potty/surfaces/curses_surface.rb
66
+ - lib/potty/surfaces/inline_surface.rb
67
+ - lib/potty/theme.rb
68
+ - lib/potty/version.rb
69
+ - lib/potty/view.rb
70
+ - lib/potty/widgets/base.rb
71
+ - lib/potty/widgets/button.rb
72
+ - lib/potty/widgets/checkbox_group.rb
73
+ - lib/potty/widgets/colored_fields_item.rb
74
+ - lib/potty/widgets/container.rb
75
+ - lib/potty/widgets/countdown.rb
76
+ - lib/potty/widgets/flash_message.rb
77
+ - lib/potty/widgets/label.rb
78
+ - lib/potty/widgets/list.rb
79
+ - lib/potty/widgets/list_item.rb
80
+ - lib/potty/widgets/panel.rb
81
+ - lib/potty/widgets/progress_bar.rb
82
+ - lib/potty/widgets/radio_group.rb
83
+ - lib/potty/widgets/spinner.rb
84
+ - lib/potty/widgets/status_bar.rb
85
+ - lib/potty/widgets/text_input.rb
86
+ - lib/potty/widgets/toggle.rb
87
+ - lib/potty/window_manager.rb
88
+ homepage: https://github.com/TwilightCoders/potty
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ source_code_uri: https://github.com/TwilightCoders/potty
93
+ changelog_uri: https://github.com/TwilightCoders/potty/blob/main/CHANGELOG.md
94
+ rubygems_mfa_required: 'true'
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 2.7.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.6.9
110
+ specification_version: 4
111
+ summary: A curses-based terminal UI framework for Ruby
112
+ test_files: []