├── .gitignore
├── README.md
├── examples
├── asynchronous.cr
├── colors.cr
├── file_manager.cr
├── keypress.cr
├── list.cr
├── moving_elements.cr
├── prompts.cr
├── state.cr
├── websocket_application.cr
└── websocket_client.cr
├── shard.lock
├── shard.yml
├── spec
└── hydra
│ ├── application_spec.cr
│ ├── element_spec.cr
│ ├── extended_string_spec.cr
│ ├── grid_spec.cr
│ ├── list_spec.cr
│ ├── prompt_spec.cr
│ ├── text_spec.cr
│ └── view_spec.cr
└── src
├── hydra.cr
└── hydra
├── application.cr
├── binding.cr
├── border_filter.cr
├── cell.cr
├── color.cr
├── element.cr
├── element_collection.cr
├── event.cr
├── event_hub.cr
├── extended_string.cr
├── filter.cr
├── grid.cr
├── keypress.cr
├── list.cr
├── logbox.cr
├── prompt.cr
├── screen.cr
├── state.cr
├── string_parser.cr
├── terminal_screen.cr
├── text.cr
└── view.cr
/.gitignore:
--------------------------------------------------------------------------------
1 | lib
2 | .shards
3 | *.log
4 | ncurses
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hydra
2 |
3 | Hydra is a terminal interface library written in crystal.
4 |
5 | ## Disclaimer
6 |
7 | This is my first project in Crystal and my first interface library, so the code may go through various changes.
8 |
9 | I would be very happy to have propositions on how to improve the code / the architecture of the library.
10 |
11 | Also, I should write more test :shame:
12 |
13 | ## Installation
14 |
15 | You'll need to install the dependencies using `shards install`
16 |
17 | You'll also need the development libraries for libtermbox.
18 |
19 | Example for Manjaro:
20 |
21 | yaourt -S termbox-git
22 |
23 | ## Example application
24 |
25 | You can run [file_manager.cr](examples/file_manager.cr) in order to see the library in action.
26 |
27 | crystal run examples/file_manager.cr
28 |
29 | ## Features
30 |
31 | There are various examples that showcase the different features.
32 |
33 | ### Application
34 |
35 | Minimal setup is:
36 |
37 | app = Hydra::Application.setup # => Nothing happens for the user
38 |
39 | # Once the application is running, pressing ctrl-c will stop it.
40 | app.bind("keypress.ctrl-c", "application", "stop")
41 |
42 | # Define elements and events here
43 |
44 | app.run # => Screen is cleared and the application is displayed
45 |
46 | # The application will loop until ctrl-c is pressed
47 |
48 | app.teardown # => Reset the screen
49 |
50 | ### Elements
51 |
52 | You can add elements and display them on the screen.
53 |
54 | app = Hydra::Application.setup
55 | app.add_element({
56 | :id => "my-text",
57 | :type => "text",
58 | :value => "Hello World",
59 | })
60 |
61 | el = Hydra::Element.new(...)
62 | app.add_element(el)
63 |
64 | An element must have a unique id and a type, see bellow for available types.
65 | The id is used by the events mechanism (see bellow).
66 |
67 | Elements are visible by default and can be hidden:
68 |
69 | el = Hydra::Element.new(...) # => el is visible
70 | el = Hydra::Element.new({ :visible => "false", ...}) # => el is hidden
71 |
72 | el.show
73 | el.hide
74 |
75 | Elements can be positioned:
76 |
77 | el = Hydra::Element.new({ :position => "3:7", ...}) # => el has an absolute position x = 3, y = 7
78 |
79 | el = Hydra::Element.new({ :position => "center", ...}) # => el is positioned at the bottom of the screen
80 |
81 | See `Hydra::View#render_element` for the supported values for position.
82 |
83 | Elements can then be moved:
84 |
85 | el = Hydra::Element.new({ :position => "3:7", ...}) # => el has an absolute position x = 3, y = 7
86 | el.move(4, 8) # => el is now at x = 4, y = 8
87 |
88 | You can add custom elements to your application:
89 |
90 | class MyElement < Hydra::Element
91 | # Define new methods
92 | # Override Hydra::Element#trigger or any other method
93 | end
94 |
95 | app = Hydra::Application.setup
96 | el = MyElement.new(...)
97 | app.add_element(el)
98 |
99 | #### Elements collection
100 |
101 | There is a class to handle an elements collection: `ElementsCollection`, it provides utility methods.
102 |
103 | ### Events
104 |
105 | [asynchronous.cr](examples/asynchronous.cr)
106 |
107 | #### Keypresses
108 |
109 | [keypress.cr](examples/keypress.cr)
110 |
111 | ### View
112 |
113 | [moving_elements.cr](examples/moving_elements.cr)
114 |
115 | ### State
116 |
117 | [state.cr](examples/state.cr)
118 |
119 | ### Colors / ExtendedString
120 |
121 | [colors.cr](examples/colors.cr)
122 |
123 | ### Logger
124 |
125 | ## Elements
126 |
127 | #### Text
128 |
129 | The text element is used to display single or multiline text.
130 |
131 | #### Prompt
132 |
133 | This is your <input type="text"> element: it allows the user to enter a string.
134 |
135 | [prompts.cr](examples/promps.cr)
136 |
137 | #### List
138 |
139 | A list of various elements, user can select one.
140 |
141 | [list.cr](examples/list.cr)
142 |
143 | #### LogBox
144 |
145 | A box that display the latest messages it has received. Scroll included.
146 |
147 | ## Examples
148 |
149 | * [asynchronous.cr](examples/asynchronous.cr)
150 | * [colors.cr](examples/colors.cr)
151 | * [file_manager.cr](examples/file_manager.cr)
152 | * [keypress.cr](examples/keypress.cr)
153 | * [list.cr](examples/list.cr)
154 | * [moving_elements.cr](examples/moving_elements.cr)
155 | * [prompts.cr](examples/promps.cr)
156 | * [state.cr](examples/state.cr)
157 | * [websocket_application.cr](examples/websocket_application)
158 | * [websocket_client.cr](examples/websocket_client)
159 |
160 | ## Contributing
161 |
162 | 1. Fork it (https://github.com/Ghrind/hydra/fork)
163 | 2. Create your feature branch (git checkout -b my-new-feature)
164 | 3. Commit your changes (git commit -am 'Add some feature')
165 | 4. Push to the branch (git push origin my-new-feature)
166 | 5. Create a new Pull Request
167 |
168 | Contributors
169 |
170 | * Ghrind - Benoît Dinocourt - creator, maintainer
171 |
--------------------------------------------------------------------------------
/examples/asynchronous.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.add_element({
6 | :id => "commands",
7 | :type => "text",
8 | :value => "Press q to quit immediately\nPress s to quit in 2 seconds",
9 | :label => "Commands"
10 | })
11 |
12 | # Pressing q will quit
13 | app.bind("keypress.q", "application", "stop")
14 |
15 | # Pressing s will quit in 2 seconds
16 | app.bind("keypress.s") do |event_hub|
17 | spawn do
18 | sleep 2
19 | event_hub.trigger("application", "stop")
20 | end
21 | true
22 | end
23 |
24 | app.run
25 | app.teardown
26 |
--------------------------------------------------------------------------------
/examples/colors.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.bind("keypress-q", "application", "close")
6 |
7 |
8 |
9 | app.add_element({
10 | :type => "text",
11 | :id => "text",
12 | :value => "The word red is red\nThis text is green\nThis is blue and red"
13 | })
14 |
15 | # Pressing ctrl-c will quit
16 | app.bind("keypress.ctrl-c", "application", "stop")
17 |
18 | app.run
19 | app.teardown
20 |
--------------------------------------------------------------------------------
/examples/file_manager.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | include Hydra
4 |
5 | class FileManager < Application
6 | private def update_state
7 | filename = current_filename
8 | if filename
9 | @state["file_name"] = filename
10 | @state["file_size"] = File.size(filename).to_s
11 | end
12 | end
13 |
14 | def rename_current_file(new_name : String)
15 | if current_filename
16 | File.rename(current_filename.as(String), new_name)
17 | update_file_list
18 | message("Renaming #{current_filename} -> #{new_name}")
19 | end
20 | end
21 |
22 | def message(text : String)
23 | @event_hub.trigger("logbox", "add_message", { "message" => text })
24 | end
25 |
26 | def update_file_list
27 | list = @elements.by_id("file-list").as(List)
28 | list.clear
29 | Dir.glob("*").sort.each do |entry|
30 | list.add_item(entry)
31 | end
32 | end
33 |
34 | private def current_filename
35 | list = @elements.by_id("file-list").as(List)
36 | if list.selected
37 | list.value
38 | end
39 | end
40 |
41 | private def update_screen
42 | update_state
43 | super
44 | end
45 | end
46 |
47 | app = FileManager.setup
48 |
49 | # File list
50 |
51 | file_list = List.new("file-list", {
52 | :label => "Files",
53 | :position => "0:0",
54 | :width => "40",
55 | :height => "25",
56 | })
57 | app.add_element(file_list)
58 |
59 | app.bind("ready") do |event_hub, _, elements, _|
60 | app.update_file_list
61 | event_hub.focus("file-list")
62 | true
63 | end
64 |
65 | app.bind("keypress.j") do |event_hub, _, elements, state|
66 | event_hub.trigger("file-list", "select_down")
67 | true
68 | end
69 |
70 | app.bind("keypress.k") do |event_hub, _, elements, state|
71 | event_hub.trigger("file-list", "select_up")
72 | true
73 | end
74 |
75 | # Info panel
76 |
77 | info_panel_template = "Name: {{file_name}}\n" \
78 | "Size: {{file_size}} b"
79 |
80 | info_panel = Text.new("info-panel", {
81 | :height => "16",
82 | :label => "File info",
83 | :position => "0:39",
84 | :template => info_panel_template,
85 | :width => "40",
86 | })
87 | app.add_element(info_panel)
88 |
89 | # Rename prompt
90 |
91 | rename_prompt = Prompt.new("rename-prompt", {
92 | :height => "3",
93 | :label => "Rename file:",
94 | :position => "10:35",
95 | :visible => "false",
96 | :width => "30",
97 | :z_index => "1",
98 | })
99 | app.add_element(rename_prompt)
100 |
101 | app.bind("file-list", "keypress.r") do |event_hub, _, elements, _|
102 | prompt = elements.by_id("rename-prompt").as(Prompt)
103 | prompt.show
104 | event_hub.focus(prompt.id)
105 | false
106 | end
107 |
108 | app.bind("rename-prompt", "keypress.enter") do |event_hub, _, elements, _|
109 | prompt = elements.by_id("rename-prompt").as(Prompt)
110 | app.rename_current_file(prompt.value)
111 | prompt.hide
112 | prompt.clear
113 | event_hub.focus("file-list")
114 | false
115 | end
116 |
117 | # Commands
118 |
119 | commands = Text.new("commands", {
120 | :height => "10",
121 | :label => "Commands",
122 | :value => "Use j and k to select file\nPress r to rename file\nPress q to quit",
123 | :position => "15:39",
124 | :width => "40",
125 | })
126 | app.add_element(commands)
127 |
128 | # Logbox
129 |
130 | logbox = Logbox.new("logbox", {
131 | :height => "6",
132 | :label => "Messages",
133 | :position => "24:0",
134 | :width => "79",
135 | })
136 | app.add_element(logbox)
137 |
138 | # Exit conditions
139 |
140 | # Pressing q will quit
141 | app.bind("file-list", "keypress.q", "application", "stop")
142 |
143 | app.bind("keypress.ctrl-c", "application", "stop")
144 |
145 | app.run
146 |
147 | app.teardown
148 |
--------------------------------------------------------------------------------
/examples/keypress.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.add_element({
6 | :id => "logbox",
7 | :type => "logbox",
8 | :label => "Messages"
9 | })
10 |
11 | app.bind("keypress.*") do |event_hub, event|
12 | event_hub.trigger("logbox", "add_message", { "message" => "#{Hydra::ExtendedString.escape(event.keypress.inspect)}" })
13 | true
14 | end
15 |
16 | # Pressing ctrl-c will quit
17 | app.bind("keypress.ctrl-c", "application", "stop")
18 |
19 | app.run
20 | app.teardown
21 |
--------------------------------------------------------------------------------
/examples/list.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.add_element({
6 | :id => "my-list",
7 | :type => "list",
8 | :height => "10",
9 | :label => "Select an item"
10 | })
11 |
12 | app.add_element({
13 | :id => "",
14 | :type => "text",
15 | :position => "12:0",
16 | :template => "Yummy yummy {{selected}}!",
17 | :label => "Selected",
18 | :width => "30"
19 | })
20 |
21 | app.bind("ready") do |_, _, elements, state|
22 | list = elements.by_id("my-list").as(Hydra::List)
23 | list.add_item "Apple"
24 | list.add_item "Banana"
25 | list.add_item "Cherries"
26 | list.add_item "Date"
27 | list.add_item "Elderberry"
28 | list.add_item "Fig"
29 | list.add_item "Grape"
30 | list.add_item "Honeyberry"
31 | list.add_item "Jackfruit"
32 | list.add_item "Kumquat"
33 | list.add_item "Lemon"
34 | list.add_item "Mango"
35 | list.add_item "Nectarine"
36 | list.add_item "Orange"
37 | list.add_item "Pear"
38 |
39 | state["selected"] = list.value
40 | true
41 | end
42 |
43 | app.bind("keypress.j") do |event_hub, _, elements, state|
44 | event_hub.trigger("my-list", "select_down")
45 | list = elements.by_id("my-list")
46 | state["selected"] = list.value
47 | true
48 | end
49 |
50 | app.bind("keypress.k") do |event_hub, _, elements, state|
51 | event_hub.trigger("my-list", "select_up")
52 | list = elements.by_id("my-list")
53 | state["selected"] = list.value
54 | true
55 | end
56 |
57 | # Pressing q will quit
58 | app.bind("keypress.q", "application", "stop")
59 |
60 | app.run
61 | app.teardown
62 |
--------------------------------------------------------------------------------
/examples/moving_elements.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.add_element({
6 | :id => "commands",
7 | :type => "text",
8 | :position => "center",
9 | :value => "Press h to move left\nPress j to move down\nPress k to move up\nPress l to move right\nPress q to quit",
10 | :label => "Commands"
11 | })
12 |
13 | app.add_element({
14 | :id => "text_1",
15 | :type => "text",
16 | :position => "0:0",
17 | :value => " \n Move me \n "
18 | })
19 |
20 | app.bind("keypress.h") do |_, _, elements|
21 | element = elements.by_id("text_1")
22 | element.move(0, -1)
23 | true
24 | end
25 |
26 | app.bind("keypress.j") do |_, _, elements|
27 | element = elements.by_id("text_1")
28 | element.move(1, 0)
29 | true
30 | end
31 |
32 | app.bind("keypress.k") do |_, _, elements|
33 | element = elements.by_id("text_1")
34 | element.move(-1, 0)
35 | true
36 | end
37 |
38 | app.bind("keypress.l") do |_, _, elements|
39 | element = elements.by_id("text_1")
40 | element.move(0, 1)
41 | true
42 | end
43 |
44 | # Pressing q will quit
45 | app.bind("keypress.q", "application", "stop")
46 |
47 | app.run
48 | app.teardown
49 |
--------------------------------------------------------------------------------
/examples/prompts.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.add_element({
6 | :id => "prompt-2",
7 | :type => "prompt",
8 | :position => "center",
9 | :visible => "false",
10 | :label => "Prompt 2"
11 | })
12 |
13 | app.add_element({
14 | :id => "prompt-1",
15 | :type => "prompt",
16 | :position => "center",
17 | :visible => "false",
18 | :label => "Prompt 1"
19 | })
20 |
21 | app.add_element({
22 | :id => "logbox",
23 | :type => "logbox",
24 | :position => "bottom-left",
25 | :label => "Messages"
26 | })
27 |
28 | app.add_element({
29 | :id => "",
30 | :type => "text",
31 | :value => "Press c to show Prompt 1\nPress d to show Prompt 2\nPress ctrl-x to hide prompts\nPress q to quit",
32 | :label => "Commands"
33 | })
34 |
35 | # Create two prompts, activated by a different character
36 | { 1 => "c", 2 => "d" }.each do |index, char|
37 | app.bind("application", "keypress.#{char}") do |event_hub|
38 | event_hub.trigger("prompt-#{index}", "show")
39 | event_hub.focus("prompt-#{index}")
40 | false
41 | end
42 |
43 | app.bind("prompt-#{index}", "keypress.enter") do |event_hub, event, elements|
44 | event_hub.trigger("prompt-#{index}", "hide")
45 | event_hub.unfocus
46 | element = elements.by_id("prompt-#{index}")
47 | event_hub.trigger("logbox", "add_message", { "message" => "Prompt #{index}: '#{Hydra::ExtendedString.escape(element.value)}'" })
48 | event_hub.trigger("prompt-#{index}", "clear")
49 | false
50 | end
51 | end
52 |
53 | # Pressing ctrl + x will close all prompts
54 | app.bind("keypress.ctrl-x") do |event_hub|
55 | event_hub.trigger("prompt-1", "hide")
56 | event_hub.trigger("prompt-1", "clear")
57 | event_hub.trigger("prompt-2", "hide")
58 | event_hub.trigger("prompt-2", "clear")
59 | event_hub.unfocus
60 | true
61 | end
62 |
63 | # Pressing q will quit
64 | app.bind("application", "keypress.q", "application", "stop")
65 |
66 | app.run
67 |
68 | app.teardown
69 |
--------------------------------------------------------------------------------
/examples/state.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 |
3 | app = Hydra::Application.setup
4 |
5 | app.add_element({
6 | :id => "prompt-1",
7 | :type => "prompt",
8 | :position => "center",
9 | :visible => "false",
10 | :label => "User name"
11 | })
12 |
13 | app.add_element({
14 | :id => "",
15 | :type => "text",
16 | :position => "7:0",
17 | :template => "{{player.name}}",
18 | :label => "User name"
19 | })
20 |
21 | app.add_element({
22 | :id => "commands",
23 | :type => "text",
24 | :value => "Press c to show Prompt\nPress ctrl-x to hide prompt\nPress q to quit",
25 | :label => "Commands"
26 | })
27 |
28 | app.bind("ready") do |_, _, _, state|
29 | state["player.name"] = "John Doe"
30 | true
31 | end
32 |
33 | app.bind("keypress.c") do |event_hub|
34 | if event_hub.has_focus?("prompt-1")
35 | true
36 | else
37 | event_hub.trigger("prompt-1", "show")
38 | event_hub.focus("prompt-1")
39 | false
40 | end
41 | end
42 |
43 | app.bind("prompt-1", "keypress.enter") do |event_hub, event, elements, state|
44 | event_hub.trigger("prompt-1", "hide")
45 | event_hub.unfocus
46 | element = elements.by_id("prompt-1")
47 | state["player.name"] = Hydra::ExtendedString.escape(element.value)
48 | event_hub.trigger("prompt-1", "clear")
49 | false
50 | end
51 |
52 | # Pressing ctrl + x will close prompt
53 | app.bind("keypress.ctrl-x") do |event_hub|
54 | event_hub.trigger("prompt-1", "hide")
55 | event_hub.unfocus
56 | true
57 | end
58 |
59 | # Pressing q will quit
60 | app.bind("application", "keypress.q", "application", "stop")
61 |
62 | app.run
63 | app.teardown
64 |
--------------------------------------------------------------------------------
/examples/websocket_application.cr:
--------------------------------------------------------------------------------
1 | require "../src/hydra"
2 | require "socket"
3 | require "json"
4 |
5 | puts "Waiting for client to connect..."
6 | puts "Use `crystal run example/websocket_client.cr` to run the client"
7 | server = TCPServer.new("0.0.0.0", 8080)
8 | socket = server.accept
9 |
10 | app = Hydra::Application.setup
11 |
12 | app.add_element({
13 | :id => "message",
14 | :template => "{{message}}",
15 | :type => "text"
16 | })
17 |
18 | app.add_element({
19 | :id => "prompt",
20 | :type => "prompt",
21 | :position => "4:0"
22 | })
23 |
24 | app.bind("ready") do |event_hub, _, _, state|
25 | state["message"] = "..."
26 | event_hub.focus("prompt")
27 | true
28 | end
29 |
30 | app.bind("keypress.enter") do |_, _, elements, state|
31 | prompt = elements.by_id("prompt")
32 | socket.puts Hydra::ExtendedString.escape(prompt.value)
33 | message = socket.gets
34 | state["message"] = message if message
35 | true
36 | end
37 |
38 | # Pressing ctrl-c will quit
39 | app.bind("keypress.ctrl-c", "application", "stop")
40 |
41 | app.run
42 | app.teardown
43 |
--------------------------------------------------------------------------------
/examples/websocket_client.cr:
--------------------------------------------------------------------------------
1 | # This is a client that is supposed to communicate with the application.cr example
2 | require "socket"
3 | require "json"
4 |
5 | s = TCPSocket.new "localhost", 8080
6 |
7 | #payload = [
8 | # { "element" => "log_box", "height" => 11, "width" => 60 },
9 | # { "bind" => "keypress.page_up", "target" => "logbox", "behavior" => "scroll_up" },
10 | # { "bind" => "keypress.page_down", "target" => "logbox", "behavior" => "scroll_down" },
11 | # { "bind" => "keypress.c", "target" => "client", "behavior" => "say_hello" }
12 | #]
13 | #
14 | #10.times do |i|
15 | # payload.push({ "do" => "add_message", "target" => "logbox", "params" => i.to_s })
16 | #end
17 | #
18 | #s.puts payload.to_json
19 |
20 | while true
21 | msg = s.gets
22 | puts msg
23 | s.puts "Coucou #{msg}"
24 | end
25 |
26 | s.close
27 |
--------------------------------------------------------------------------------
/shard.lock:
--------------------------------------------------------------------------------
1 | version: 1.0
2 | shards:
3 | termbox:
4 | github: andrewsuzuki/termbox-crystal
5 | commit: 9471d1f1852dec251d6bcb6f8a583e7abdf04f68
6 |
7 |
--------------------------------------------------------------------------------
/shard.yml:
--------------------------------------------------------------------------------
1 | name: hydra
2 | version: 0.4.0
3 |
4 | license: MIT
5 |
6 | dependencies:
7 | termbox:
8 | github: andrewsuzuki/termbox-crystal
9 |
--------------------------------------------------------------------------------
/spec/hydra/application_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/application"
3 |
4 | class TestScreen < Hydra::Screen
5 | @chars : Array(Int32)
6 | property :chars
7 |
8 | def getch() Hydra::Keypress
9 | return nil if @chars.size.zero?
10 | Hydra::Keypress.new(UInt32.new(@chars.shift))
11 | end
12 |
13 | def initialize
14 | @chars = Array(Int32).new
15 | super
16 | end
17 | end
18 |
19 | describe "Application" do
20 | describe ".setup" do
21 | it "should setup a whole application properly" do
22 | # NOTE: For some reason, instanciating the application triggers the
23 | # Termbox::Window shutdown
24 | application = Hydra::Application.setup
25 | application.teardown
26 | end
27 | end
28 |
29 | it "should start an application and stop it" do
30 | screen = TestScreen.new
31 | screen.chars = [24] # 24 => ctrl-x
32 |
33 | app = Hydra::Application.setup(screen: screen)
34 | app.bind("keypress.ctrl-x", "application", "stop")
35 | app.run
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/spec/hydra/element_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/element"
3 |
4 | describe "Element" do
5 | describe "#position" do
6 | it "defaults as '0:0'" do
7 | element = Hydra::Element.new("id")
8 | element.position.should eq "0:0"
9 | end
10 | end
11 | describe "#visible" do
12 | it "defaults as true" do
13 | element = Hydra::Element.new("id")
14 | element.visible.should eq true
15 | end
16 | end
17 | describe "#id" do
18 | it "is defined during initialization" do
19 | element = Hydra::Element.new("foobar")
20 | element.id.should eq "foobar"
21 | end
22 | end
23 | describe "#width" do
24 | it "has a default value" do
25 | element = Hydra::Element.new("foobar")
26 | element.width.should eq 12
27 | end
28 | end
29 | describe "#height" do
30 | it "has a default value" do
31 | element = Hydra::Element.new("foobar")
32 | element.height.should eq 3
33 | end
34 | end
35 | describe "#hide" do
36 | it "sets visible to false" do
37 | element = Hydra::Element.new("id")
38 | element.hide
39 | element.visible.should eq false
40 | end
41 | end
42 | describe "#content" do
43 | it "returns a generic message" do
44 | element = Hydra::Element.new("id")
45 | element.content.string.should eq "Content for Hydra::Element is undefined"
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/spec/hydra/extended_string_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/extended_string"
3 |
4 | describe "ExtendedString" do
5 | describe "#chunks" do
6 | context "with simple markup" do
7 | it "parses tags properly" do
8 | str = Hydra::ExtendedString.new("Hi Nathan!")
9 | str.chunks.map { |i| i.string }.should eq ["Hi ", "Nathan", "!"]
10 | str.chunks.map { |i| i.tags }.should eq [Array(String).new, ["red-fg"], Array(String).new]
11 | end
12 | end
13 | context "with complex markup" do
14 | it "parses tags properly" do
15 | str = Hydra::ExtendedString.new("Hi Nathan!")
16 | str.chunks.map { |i| i.string }.should eq ["Hi ", "Nathan", "!"]
17 | str.chunks.map { |i| i.tags }.should eq [Array(String).new, ["bold", "red-fg"], ["bold"]]
18 | end
19 | end
20 | context "when closing a tag that is not open" do
21 | it "ignores the closing tag" do
22 | str = Hydra::ExtendedString.new("AB")
23 | str.chunks.map { |i| i.string }.should eq ["A", "B"]
24 | str.chunks.map { |i| i.tags }.should eq [Array(String).new, Array(String).new]
25 | end
26 | end
27 | context "when a tag is escaped" do
28 | it "treats the tag as plain text" do
29 | str = Hydra::ExtendedString.new("\\bla\\")
30 | str.chunks.map { |i| i.string }.should eq ["bla"]
31 | str.chunks.map { |i| i.tags }.should eq [Array(String).new]
32 | end
33 | end
34 | context "when there is a '\' in the string" do
35 | it "parses properly" do
36 | str = Hydra::ExtendedString.new("\\")
37 | str.chunks.map { |i| i.string }.should eq ["\\"]
38 |
39 | str = Hydra::ExtendedString.new("a\\")
40 | str.chunks.map { |i| i.string }.should eq ["a\\"]
41 |
42 | str = Hydra::ExtendedString.new("\\a")
43 | str.chunks.map { |i| i.string }.should eq ["\\a"]
44 |
45 | str = Hydra::ExtendedString.new("a\\a")
46 | str.chunks.map { |i| i.string }.should eq ["a\\a"]
47 | end
48 | end
49 | end
50 | describe ".escape" do
51 | it "escapes tags" do
52 | Hydra::ExtendedString.escape("").should eq "\\"
53 | end
54 | end
55 | describe "#stripped" do
56 | it "returns the string with no markup" do
57 | str = Hydra::ExtendedString.new("Hi Nathan!")
58 | str.stripped.should eq "Hi Nathan!"
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/spec/hydra/grid_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/grid"
3 |
4 | def test_grid(x = 3, y = 3) Hydra::Grid(String)
5 | grid = Hydra::Grid(String).new(x, y)
6 | grid.fill_with(" ")
7 | grid
8 | end
9 |
10 | describe "Grid" do
11 | describe "#[]" do
12 | it "returns the value at x,y" do
13 | grid = test_grid
14 | grid[1, 2] = "a"
15 | grid[1, 2].should eq "a"
16 | end
17 | context "when x,y are out of bounds" do
18 | it "returns nil" do
19 | grid = Hydra::Grid(String).new(0, 0)
20 | grid[1, 1].should eq nil
21 | end
22 | end
23 | end
24 | describe "#[]=" do
25 | it "sets the value at coordinates" do
26 | grid = test_grid
27 | grid[1, 2] = "a"
28 | grid.dump.should eq [" ",
29 | " a",
30 | " "].join("\n")
31 | end
32 | end
33 |
34 | describe "#fill_with" do
35 | it "fill the whole grid with the same value" do
36 | grid = test_grid
37 | grid[1, 2] = "a"
38 | grid.fill_with(".")
39 | grid.dump.should eq ["...",
40 | "...",
41 | "..."].join("\n")
42 | end
43 | end
44 |
45 | describe "#clear" do
46 | it "removes all values" do
47 | grid = test_grid
48 | grid[1, 2] = "a"
49 | grid.clear
50 | grid.dump.should eq ["",
51 | "",
52 | ""].join("\n")
53 | end
54 | end
55 |
56 | describe "#dump" do
57 | it "returns a string with the grid's content" do
58 | grid = test_grid(2, 2)
59 | grid[0, 0] = "a"
60 | grid[0, 1] = "b"
61 | grid[1, 0] = "c"
62 | grid[1, 1] = "d"
63 |
64 | grid.dump.should eq ["ab",
65 | "cd"].join("\n")
66 | end
67 | end
68 |
69 | describe "#each" do
70 | it "iterates through all the elements, with their indexes" do
71 | grid = test_grid(2, 2)
72 | grid[0, 0] = "a"
73 | grid[0, 1] = "b"
74 | grid[1, 0] = "c"
75 | grid[1, 1] = "d"
76 |
77 | result = Array(String).new
78 |
79 | grid.each do |char, x, y|
80 | result.push("#{char},#{x},#{y}")
81 | end
82 |
83 | result.should eq ["a,0,0", "b,0,1", "c,1,0", "d,1,1"]
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/spec/hydra/list_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/list"
3 |
4 | describe "List" do
5 | describe "#inner_height" do
6 | it "is equal to the height without border" do
7 | list = Hydra::List.new("")
8 | list.height = 3
9 | list.inner_height.should eq 1
10 |
11 | list = Hydra::List.new("")
12 | list.height = 4
13 | list.inner_height.should eq 2
14 | end
15 | end
16 | context "when it is empty" do
17 | it "shows an empty box" do
18 | list = Hydra::List.new("")
19 | list.height = 3
20 | list.width = 10
21 | list.content.stripped.should eq ["┌────────┐",
22 | "│ │",
23 | "└────────┘"].join("\n")
24 | end
25 | end
26 | context "when there are items" do
27 | it "shows the item" do
28 | list = Hydra::List.new("")
29 | list.height = 4
30 | list.width = 10
31 | list.add_item("foobar")
32 | list.add_item("barfoo")
33 | list.content.stripped.should eq ["┌────────┐",
34 | "│foobar │",
35 | "│barfoo │",
36 | "└────────┘"].join("\n")
37 | end
38 | end
39 | context "when the items overflow from the list's height" do
40 | it "shows the scroll down indicator" do
41 | list = Hydra::List.new("")
42 | list.height = 3
43 | list.width = 10
44 | list.add_item("foobar")
45 | list.add_item("barfoo")
46 | list.content.stripped.should eq ["┌────────┐",
47 | "│foobar │",
48 | "└────────↓"].join("\n")
49 | end
50 |
51 | context "once we scroll down" do
52 | it "shows the scroll up indicator" do
53 | list = Hydra::List.new("")
54 | list.height = 3
55 | list.width = 10
56 | list.add_item("foobar")
57 | list.add_item("barfoo")
58 | list.scroll(-1)
59 | list.content.string.should eq ["┌────────↑",
60 | "│barfoo │",
61 | "└────────┘"].join("\n")
62 | end
63 | end
64 | context "when there items above and below" do
65 | it "shows both scroll indicators" do
66 | list = Hydra::List.new("")
67 | list.height = 4
68 | list.width = 10
69 | list.add_item("foobar")
70 | list.add_item("barfoo")
71 | list.add_item("barboo")
72 | list.add_item("farboo")
73 | list.scroll(-1)
74 | list.content.string.should eq ["┌────────↑",
75 | "│barfoo │",
76 | "│barboo │",
77 | "└────────↓"].join("\n")
78 | end
79 | end
80 | context "when an item is selected" do
81 | it "should have a visual cue" do
82 | list = Hydra::List.new("")
83 | list.height = 4
84 | list.width = 10
85 | list.add_item("foobar")
86 | list.add_item("barfoo")
87 | list.select_item(0)
88 | list.content.string.should eq ["┌────────┐",
89 | "│foobar │",
90 | "│barfoo │",
91 | "└────────┘"].join("\n")
92 | end
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/spec/hydra/prompt_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/prompt"
3 |
4 | describe "Prompt" do
5 | describe "#content" do
6 | it "displays a boxed version of the content" do
7 | prompt = Hydra::Prompt.new("id")
8 | prompt.append("foobar")
9 | prompt.content.string.should eq ["┌────────────────────────────┐",
10 | "│foobar │",
11 | "└────────────────────────────┘"].join("\n")
12 | end
13 |
14 | context "with a label" do
15 | it "shows the label in the top bar" do
16 | prompt = Hydra::Prompt.new("id", {:label => "toto"})
17 | prompt.append("foobar")
18 | prompt.content.string.should eq ["┌─toto───────────────────────┐",
19 | "│foobar │",
20 | "└────────────────────────────┘"].join("\n")
21 | end
22 | end
23 |
24 | context "when the value is bigger than the size of the prompt" do
25 | it "shows the end of the value" do
26 | prompt = Hydra::Prompt.new("id")
27 | prompt.append("abcdefghijklmnopqrstuvwxyz1234567890")
28 | prompt.content.string.should eq ["┌────────────────────────────┐",
29 | "│…jklmnopqrstuvwxyz1234567890│",
30 | "└────────────────────────────┘"].join("\n")
31 | end
32 | end
33 | end
34 |
35 | describe "#width" do
36 | it "is fixed to 30" do
37 | prompt = Hydra::Prompt.new("id")
38 | prompt.width.should eq 30
39 | end
40 | end
41 |
42 | describe "#height" do
43 | it "is fixed to 3" do
44 | prompt = Hydra::Prompt.new("id")
45 | prompt.height.should eq 3
46 | end
47 | end
48 |
49 | describe "#remove_last" do
50 | it "removes the last character of the value" do
51 | prompt = Hydra::Prompt.new("id")
52 | prompt.append("foobar")
53 | prompt.remove_last
54 | prompt.value.should eq "fooba"
55 | end
56 |
57 | context "when the value is blank" do
58 | it "leaves the value blank" do
59 | prompt = Hydra::Prompt.new("id")
60 | prompt.remove_last
61 | prompt.value.should eq ""
62 | end
63 | end
64 | end
65 |
66 | describe "#append" do
67 | it "adds the given string to the current value" do
68 | prompt = Hydra::Prompt.new("id")
69 | prompt.append("foo")
70 | prompt.append("bar")
71 | prompt.value.should eq "foobar"
72 | end
73 | end
74 |
75 | describe "#clear" do
76 | it "resets the value to a blank state" do
77 | prompt = Hydra::Prompt.new("id")
78 | prompt.append("foobar")
79 | prompt.clear
80 | prompt.value.should eq ""
81 | end
82 | end
83 |
84 | end
85 |
--------------------------------------------------------------------------------
/spec/hydra/text_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/text"
3 |
4 | describe "Text" do
5 | describe "autosize!" do
6 | it "sets the right width" do
7 | text = Hydra::Text.new("", { :autosize => "true" })
8 | text.value = "fo\nbar"
9 | text.autosize!
10 |
11 | text.width.should eq 5
12 | end
13 |
14 | it "sets the right height" do
15 | text = Hydra::Text.new("", { :autosize => "true" })
16 | text.value = "fo\nbar"
17 | text.autosize!
18 |
19 | text.height.should eq 4
20 | end
21 | end
22 |
23 | describe "#content" do
24 | it "returns the value in a box" do
25 | text = Hydra::Text.new("")
26 | text.width = 7
27 | text.height = 6
28 | text.value = "fo\nbar"
29 |
30 | text.content.string.should eq ["┌─────┐",
31 | "│fo │",
32 | "│bar │",
33 | "│ │",
34 | "│ │",
35 | "└─────┘"].join("\n")
36 | end
37 | context "when the label is longer than the content" do
38 | it "expends the box accordingly" do
39 | text = Hydra::Text.new("", { :label => "foobar" })
40 | text.value = "fo\nbar"
41 | text.autosize!
42 |
43 | text.content.string.should eq ["┌─foobar─┐",
44 | "│fo │",
45 | "│bar │",
46 | "└────────┘"].join("\n")
47 | end
48 | end
49 | context "when there is a tag on a newline" do
50 | it "justifies displays the border correctly" do
51 | text = Hydra::Text.new("")
52 | text.value = "The word red is red\nThis text is green"
53 | text.autosize!
54 |
55 | text.content.stripped.should eq ["┌───────────────────┐",
56 | "│The word red is red│",
57 | "│This text is green │",
58 | "└───────────────────┘"].join("\n")
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/spec/hydra/view_spec.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../../src/hydra/view"
3 |
4 | def dump_view(view : Hydra::View) : String
5 | dump = Array(String).new
6 | view.grid.lines.each do |line|
7 | dump << line.map { |cell| cell.char }.join("")
8 | end
9 | dump.join("\n")
10 | end
11 |
12 | class TestElement < Hydra::Element
13 | property :position
14 |
15 | def content() Hydra::ExtendedString
16 | Hydra::ExtendedString.new(@value)
17 | end
18 |
19 | def height
20 | @value.split("\n").size
21 | end
22 |
23 | def width
24 | @value.split("\n")[0].size
25 | end
26 | end
27 |
28 | describe "View" do
29 | describe "#render_element" do
30 | it "renders the content of an element at the right position" do
31 | view = Hydra::View.new(5, 10)
32 |
33 | element = TestElement.new("1")
34 | element.position = "2:1"
35 | element.value = "abc\ndef\nghi"
36 |
37 | view.render_element(element)
38 | dump_view(view).should eq [" ",
39 | " ",
40 | " abc ",
41 | " def ",
42 | " ghi "].join("\n")
43 | end
44 |
45 | context "when the element is centered" do
46 | it "renders the content of the element at the center of the canvas" do
47 | view = Hydra::View.new(5, 10)
48 |
49 | element = TestElement.new("1")
50 | element.position = "center"
51 | element.value = "abc\ndef\nghi"
52 |
53 | view.render_element(element)
54 | dump_view(view).should eq [" ",
55 | " abc ",
56 | " def ",
57 | " ghi ",
58 | " "].join("\n")
59 | end
60 | end
61 |
62 | end
63 | describe "#render" do
64 | context "with the border filter" do
65 | context "when two borders are overlapping" do
66 | it "merges borders" do
67 | view = Hydra::View.new(5, 10)
68 | view.filters << Hydra::BorderFilter
69 |
70 | box = ["┌─┐",
71 | "│ │",
72 | "└─┘"].join("\n")
73 |
74 | element_1 = TestElement.new("1")
75 | element_1.position = "0:0"
76 | element_1.value = box
77 |
78 | element_2 = TestElement.new("2")
79 | element_2.position = "0:2"
80 | element_2.value = box
81 |
82 | view.render([element_1, element_2])
83 | dump_view(view).should eq ["┌─┬─┐ ",
84 | "│ │ │ ",
85 | "└─┴─┘ ",
86 | " ",
87 | " "].join("\n")
88 | end
89 | end
90 | context "when the element's position is bottom-left" do
91 | it "renders the element in the bottom left corner" do
92 | view = Hydra::View.new(4, 4)
93 | element = TestElement.new("1")
94 | element.position = "bottom-left"
95 | element.value = "##\n##"
96 |
97 | view.render([element])
98 |
99 | dump_view(view).should eq [" ",
100 | " ",
101 | "## ",
102 | "## "].join("\n")
103 | end
104 | end
105 | context "when four borders are overlapping" do
106 | it "merges borders" do
107 | view = Hydra::View.new(5, 10)
108 | view.filters << Hydra::BorderFilter
109 |
110 | box = ["┌─┐",
111 | "│ │",
112 | "└─┘"].join("\n")
113 |
114 | element_1 = TestElement.new("1")
115 | element_1.position = "0:0"
116 | element_1.value = box
117 |
118 | element_2 = TestElement.new("2")
119 | element_2.position = "0:2"
120 | element_2.value = box
121 |
122 | element_3 = TestElement.new("3")
123 | element_3.position = "2:0"
124 | element_3.value = box
125 |
126 | element_4 = TestElement.new("4")
127 | element_4.position = "2:2"
128 | element_4.value = box
129 |
130 | view.render([element_1, element_2, element_3, element_4])
131 | dump_view(view).should eq ["┌─┬─┐ ",
132 | "│ │ │ ",
133 | "├─┼─┤ ",
134 | "│ │ │ ",
135 | "└─┴─┘ "].join("\n")
136 | end
137 | end
138 | end
139 | context "when the element has a template" do
140 | it "renders the content of the template" do
141 | view = Hydra::View.new(5, 10)
142 |
143 | element = TestElement.new("1")
144 | element.position = "center"
145 | element.value = "foobar" # In a real case scenario, value should be be initialized if a template is given
146 | element.template = "{{a}}"
147 |
148 | view.render([element])
149 | dump_view(view).should eq [" ",
150 | " ",
151 | " {{a}} ",
152 | " ",
153 | " "].join("\n")
154 | end
155 |
156 | it "replaces the bindings with values from the state" do
157 | view = Hydra::View.new(5, 10)
158 |
159 | element = TestElement.new("1")
160 | element.position = "center"
161 | element.template = "{{a}}"
162 |
163 | view.render([element], { "a" => "1234567890"})
164 | dump_view(view).should eq [" ",
165 | " ",
166 | "1234567890",
167 | " ",
168 | " "].join("\n")
169 | end
170 |
171 | end
172 | end
173 | end
174 |
--------------------------------------------------------------------------------
/src/hydra.cr:
--------------------------------------------------------------------------------
1 | require "./hydra/*"
2 |
3 | module Hydra
4 | end
5 |
--------------------------------------------------------------------------------
/src/hydra/application.cr:
--------------------------------------------------------------------------------
1 | require "./element_collection"
2 | require "./event"
3 | require "./event_hub"
4 | require "logger"
5 | require "./screen"
6 | require "./terminal_screen"
7 | require "./view"
8 | require "./state"
9 |
10 | module Hydra
11 | class Application
12 | getter :logger
13 |
14 | # Creates a new application with injected dependencies and sensible defaults
15 | def self.setup(event_hub : EventHub | Nil = nil,
16 | view : View | Nil = nil,
17 | logger : Logger | Nil = nil,
18 | screen : Screen | Nil = nil,
19 | elements : ElementCollection | Nil = nil,
20 | state : State | Nil = nil) : Hydra::Application
21 |
22 | event_hub = Hydra::EventHub.new unless event_hub
23 |
24 | unless logger
25 | logger = Logger.new(File.open("./debug.log", "w"))
26 | logger.level = Logger::DEBUG
27 | end
28 |
29 | screen = TerminalScreen.new unless screen
30 |
31 | unless view
32 | view = Hydra::View.new(height: screen.height, width: screen.width)
33 | view.filters << BorderFilter
34 | end
35 |
36 | elements = ElementCollection.new unless elements
37 |
38 | state = State.new unless state
39 |
40 | instance = new(view: view, event_hub: event_hub, logger: logger, screen: screen, state: state, elements: elements)
41 | event_hub.register("application", instance)
42 |
43 | instance
44 | end
45 |
46 | private def initialize(view : Hydra::View, event_hub : Hydra::EventHub, logger : Logger, screen : Screen, elements : ElementCollection, state : State)
47 | @screen = screen
48 | @view = view
49 | @logger = logger
50 | @event_hub = event_hub
51 | @elements = elements
52 | @state = state
53 | end
54 |
55 | def run
56 | @running = true
57 | @event_hub.broadcast(Event.new("ready"), @state, @elements)
58 | update_screen
59 | while @running
60 | sleep 0.01
61 | handle_keypress @screen.getch
62 | end
63 | end
64 |
65 | private def handle_keypress(keypress : Keypress | Nil)
66 | return unless keypress
67 | event = Event.new(keypress)
68 | @event_hub.broadcast(event, @state, @elements)
69 | update_screen
70 | end
71 |
72 | def teardown
73 | @screen.close
74 | end
75 |
76 | def stop
77 | @running = false
78 | end
79 |
80 | private def update_screen
81 | @view.render(@elements.to_a, @state)
82 | @screen.update(@view.grid)
83 | end
84 |
85 | def bind(focus : String, event : String, target : String, behavior : String)
86 | @event_hub.bind(focus, event, target, behavior)
87 | end
88 |
89 | def bind(event : String, target : String, behavior : String)
90 | @event_hub.bind(event, target, behavior)
91 | end
92 |
93 | def bind(focus : String, event : String, &block : EventHub, Event, ElementCollection, State -> Bool)
94 | @event_hub.bind(focus, event, &block)
95 | end
96 |
97 | def bind(event : String, &block : EventHub, Event, ElementCollection, State -> Bool)
98 | @event_hub.bind(event, &block)
99 | end
100 |
101 | def add_element(element : Element)
102 | @elements.push(element)
103 | @event_hub.register(element.id, element)
104 | end
105 |
106 | def add_element(specs : Hash(Symbol, String))
107 | element = Element.build(specs)
108 | add_element(element)
109 | end
110 |
111 | def trigger(behavior : String, payload = Hash(Symbol, String))
112 | if behavior == "stop"
113 | stop
114 | end
115 | end
116 |
117 | def on_register(event_hub : EventHub)
118 | end
119 | end
120 | end
121 |
--------------------------------------------------------------------------------
/src/hydra/binding.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class Binding
3 | getter :target
4 | getter :behavior
5 | getter :params
6 | getter :proc
7 | getter :focus
8 |
9 | def initialize(target : String, behavior : String, blocking = false, focus : String | Nil = nil, params = Hash(Symbol, String).new)
10 | @focus = focus
11 | @blocking = blocking
12 | @target = target
13 | @behavior = behavior
14 | @params = params
15 | @proc = ->( x : EventHub, y : Event, a : ElementCollection, z : State) { true }
16 | end
17 |
18 | #def initialize(target : String, behavior : String, params = Hash(Symbol, String).new)
19 | # @focus = nil
20 | # @blocking = false
21 | # @target = target
22 | # @behavior = behavior
23 | # @params = params
24 | # @proc = ->( x : EventHub, y : Event, a : ElementCollection, z : State) { true }
25 | #end
26 |
27 | def initialize(block : Proc(EventHub, Event, ElementCollection, State, Bool), focus : String | Nil = nil)
28 | @focus = focus
29 | @blocking = false # blocking is not used when there is a block, it is determined by the return value of the block
30 | @target = ""
31 | @behavior = ""
32 | @params = Hash(Symbol, String).new
33 | @proc = block
34 | end
35 |
36 | #def initialize(target : String, block : Proc(EventHub, Event, ElementCollection, State, Bool))
37 | # @focus = nil
38 | # @blocking = false
39 | # @target = target
40 | # @behavior = ""
41 | # @params = Hash(Symbol, String).new
42 | # @proc = block
43 | #end
44 |
45 | def blocking?() Bool
46 | @blocking
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/src/hydra/border_filter.cr:
--------------------------------------------------------------------------------
1 | require "./filter"
2 | module Hydra
3 | class BorderFilter < Filter
4 | def self.apply(grid : Grid(Cell)) : Grid(Cell)
5 | new(grid).apply
6 | end
7 |
8 | def initialize(grid : Grid(Cell))
9 | @grid = grid
10 | end
11 |
12 | def apply() : Grid
13 | @grid.each do |cell, x, y|
14 | char = cell.char
15 | char = connect_up(char) if connect_char_up?(x, y)
16 | char = connect_down(char) if connect_char_down?(x, y)
17 | char = connect_left(char) if connect_char_left?(x, y)
18 | char = connect_right(char) if connect_char_right?(x, y)
19 | cell.char = char
20 | @grid[x, y] = cell
21 | end
22 | @grid
23 | end
24 |
25 | def connect_char_up?(x : Int32, y : Int32) : Bool
26 | cell = @grid.[x - 1, y]
27 | return false unless cell
28 | ["│", "┐", "┬", "┌", "┤", "├", "┼"].includes?(cell.char)
29 | end
30 |
31 | def connect_up(char : String) : String
32 | case char
33 | when "┌"
34 | "├"
35 | when "┬"
36 | "┼"
37 | when "┐"
38 | "┤"
39 | when "─"
40 | "┴"
41 | else
42 | char
43 | end
44 | end
45 |
46 | def connect_char_down?(x : Int32, y : Int32) : Bool
47 | cell = @grid.[x + 1, y]
48 | return false unless cell
49 | ["│", "└", "┴", "┘", "┤", "├", "┼"].includes?(cell.char)
50 | end
51 |
52 | def connect_down(char : String) : String
53 | case char
54 | when "└"
55 | "├"
56 | when "┴"
57 | "┼"
58 | when "┘"
59 | "┤"
60 | when "─"
61 | "┬"
62 | else
63 | char
64 | end
65 | end
66 |
67 | def connect_char_left?(x : Int32, y : Int32) : Bool
68 | cell = @grid.[x, y - 1]
69 | return false unless cell
70 | ["┌", "┬", "├", "┼", "└", "┴", "─"].includes?(cell.char)
71 | end
72 |
73 | def connect_left(char : String) : String
74 | case char
75 | when "└"
76 | "┴"
77 | when "┌"
78 | "┬"
79 | when "├"
80 | "┼"
81 | when "│"
82 | "┤"
83 | else
84 | char
85 | end
86 | end
87 |
88 | def connect_char_right?(x : Int32, y : Int32) : Bool
89 | cell = @grid.[x, y + 1]
90 | return false unless cell
91 | ["┐", "┬", "┤", "┼", "┘", "┴", "─"].includes?(cell.char)
92 | end
93 |
94 | def connect_right(char : String) : String
95 | case char
96 | when "┘"
97 | "┴"
98 | when "┐"
99 | "┬"
100 | when "┤"
101 | "┼"
102 | when "│"
103 | "├"
104 | else
105 | char
106 | end
107 | end
108 |
109 | def is_grid_char?(char : String) : Bool
110 | ["┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "│", "─"].includes?(char)
111 | end
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/src/hydra/cell.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class Cell
3 | getter :tags
4 | property :char
5 | def initialize()
6 | @char = " "
7 | @tags = Array(String).new
8 | end
9 |
10 | def initialize(char : String, tags : Array(String))
11 | @char = char
12 | @tags = tags
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/src/hydra/color.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class Color
3 | COLORS = {
4 | "white" => 7,
5 | "black" => 0,
6 | "red" => 1,
7 | "green" => 2,
8 | "blue" => 4,
9 | }
10 |
11 | getter :index
12 |
13 | def initialize(color_name : String)
14 | @name = color_name
15 | @index = 0
16 | if COLORS[color_name]?
17 | @index = COLORS[color_name]
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/src/hydra/element.cr:
--------------------------------------------------------------------------------
1 | require "./extended_string"
2 |
3 | module Hydra
4 | class Element
5 |
6 | KLASSES = {
7 | "prompt" => Hydra::Prompt,
8 | "logbox" => Hydra::Logbox,
9 | "text" => Hydra::Text,
10 | "list" => Hydra::List,
11 | }
12 |
13 | getter :id
14 | getter :visible
15 | property :position
16 | @position : String
17 | property :template, :value
18 | property :width, :height, :z_index
19 |
20 | def self.build(specs : Hash(Symbol, String)) : Element
21 | id = specs.delete(:id)
22 | raise "Element is missing an id: #{specs}" unless id
23 | type = specs.delete(:type)
24 | raise "Element is missing a type: #{specs}" unless type
25 | klass = KLASSES[type]
26 | element = klass.new(id, specs)
27 | element
28 | end
29 |
30 | def initialize(id : String, options = Hash(Symbol, String).new)
31 | @id = id
32 | @position = "0:0"
33 | @position = options[:position] if options[:position]?
34 | @visible = true
35 | hide if options[:visible]? && options[:visible] == "false"
36 | @value = options[:value]? ? options[:value] : ""
37 | @template = options[:template]? ? options[:template] : ""
38 | @label = options[:label]? ? options[:label] : ""
39 | @width = options[:width]? ? options[:width].to_i : 12
40 | @height = options[:height]? ? options[:height].to_i : 3
41 | @z_index = options[:z_index]? ? options[:z_index].to_i : 0
42 | end
43 |
44 | def content() Hydra::ExtendedString
45 | Hydra::ExtendedString.new("Content for #{self.class.name} is undefined")
46 | end
47 |
48 | def show
49 | @visible = true
50 | end
51 |
52 | def hide
53 | @visible = false
54 | end
55 |
56 | def move(x : Int32, y : Int32)
57 | x1, y1 = @position.split(":").map(&.to_i)
58 | @position = "#{x1 + x}:#{y1 + y}"
59 | end
60 |
61 | def trigger(behavior : String, payload = Hash(Symbol, String).new)
62 | if behavior == "show"
63 | show
64 | elsif behavior == "hide"
65 | hide
66 | end
67 | end
68 |
69 | def on_register(event_hub : EventHub)
70 | end
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/src/hydra/element_collection.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class ElementCollection
3 | def initialize(elements = Array(Element).new)
4 | @elements = elements
5 | end
6 |
7 | def by_id(id : String) : Element
8 | e = @elements.find { |e| e.id == id }
9 | raise "Element not found #{id}" unless e
10 | e
11 | end
12 |
13 | def push(element : Element)
14 | @elements.push(element)
15 | end
16 |
17 | def show_only(*element_ids)
18 | hide_all
19 | element_ids.each do |id|
20 | by_id(id).show
21 | end
22 | end
23 |
24 | def hide_all
25 | each do |element|
26 | element.hide
27 | end
28 | end
29 |
30 | def each(&block)
31 | @elements.each do |element|
32 | yield element
33 | end
34 | end
35 |
36 | def to_a
37 | @elements
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/src/hydra/event.cr:
--------------------------------------------------------------------------------
1 | require "./keypress"
2 |
3 | module Hydra
4 | class Event
5 | property :name
6 | property :keypress
7 | @keypress : Hydra::Keypress | Nil
8 |
9 | def initialize(keypress : Keypress)
10 | @name = "keypress.#{keypress.name}"
11 | @keypress = keypress
12 | end
13 |
14 | def initialize(name : String)
15 | @name = name
16 | @keypress = nil
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/src/hydra/event_hub.cr:
--------------------------------------------------------------------------------
1 | require "./binding"
2 | require "logger"
3 |
4 | module Hydra
5 | class EventHub
6 |
7 | def self.char_to_event(char : Int32) String
8 | return "keypress.##{char}" unless CHARS_TO_EVENTS.has_key?(char)
9 | CHARS_TO_EVENTS[char]
10 | end
11 |
12 | def initialize
13 | @register = {} of String => Application | Element
14 | @bindings = {} of String => Array(Binding)
15 |
16 | @logger = Logger.new(File.open("./event_debug.log", "w"))
17 | @logger.level = Logger::DEBUG
18 |
19 | @focus = "application"
20 | end
21 |
22 | def register(key : String, triggerable : Application | Element)
23 | raise "Id already registered" if @register[key]?
24 | @register[key] = triggerable
25 | triggerable.on_register(self)
26 | end
27 |
28 | def focus(identifier : String)
29 | @focus = identifier
30 | end
31 |
32 | def unfocus()
33 | @focus = "application"
34 | end
35 |
36 | def has_focus?(identifier : String) : Bool
37 | @focus == identifier
38 | end
39 |
40 | def bind(focus : String, event : String, target : String, behavior : String)
41 | binding = Binding.new(focus: focus, target: target, behavior: behavior, blocking: true)
42 | add_binding(event, binding)
43 | end
44 |
45 | def bind(event : String, target : String, behavior : String)
46 | binding = Binding.new(target: target, behavior: behavior)
47 | add_binding(event, binding)
48 | end
49 |
50 | #def bind(event : String, &block : EventHub, Event, ElementCollection, State -> Bool)
51 | # @bindings[event] = Array(Binding).new unless @bindings.has_key?(event)
52 | # @bindings[event] << Binding.new(block)
53 | #end
54 |
55 | def bind(focus : String, event : String, &block : EventHub, Event, ElementCollection, State -> Bool)
56 | binding = Binding.new(focus: focus, block: block)
57 | add_binding(event, binding)
58 | end
59 |
60 | def bind(event : String, &block : EventHub, Event, ElementCollection, State -> Bool)
61 | binding = Binding.new(block: block)
62 | add_binding(event, binding)
63 | end
64 |
65 | private def add_binding(event, binding)
66 | @bindings[event] = Array(Binding).new unless @bindings.has_key?(event)
67 | @bindings[event] << binding
68 | end
69 |
70 | def broadcast(event : Event, state : State, elements : ElementCollection)
71 | @logger.debug "Start broacasting event #{event.name}"
72 | @logger.debug "Current focus is '#{@focus}'"
73 | # TODO: Ugly
74 | parts = event.name.split(".")
75 | parts.pop
76 | parts << "*"
77 | other_name = parts.join(".")
78 |
79 | bindings = Array(Binding).new
80 | bindings += @bindings[event.name] if @bindings[event.name]?
81 | bindings += @bindings[other_name] if @bindings[other_name]?
82 |
83 | bindings.sort { |a, b| (a.target == @focus ? 0 : 1) <=> (b.target == @focus ? 0 : 1)}.each do |binding|
84 | @logger.debug "Binding candidate: #{binding.inspect}"
85 | if binding.focus && binding.focus != @focus
86 | @logger.debug "Skipping non focused binding..."
87 | next
88 | end
89 | if binding.behavior == ""
90 | return unless binding.proc.call(self, event, elements, state)
91 | else
92 | trigger(binding.target, binding.behavior, binding.params.merge({:event => event.name}))
93 | return if binding.blocking?
94 | end
95 | end
96 | end
97 |
98 | def trigger(target : String, behavior : String, params = Hash(Symbol, String).new)
99 | return unless @register[target]?
100 | @register[target].trigger(behavior, params)
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/src/hydra/extended_string.cr:
--------------------------------------------------------------------------------
1 | require "./string_parser"
2 |
3 | module Hydra
4 | class ExtendedString
5 | property :tags
6 | property :string
7 |
8 | def self.escape(string : String) String
9 | string.gsub(StringParser::TAG_START_CHAR, "#{StringParser::ESCAPE_CHAR}#{StringParser::TAG_START_CHAR}")
10 | end
11 |
12 | def initialize(string : String)
13 | @string = string
14 | @tags = Array(String).new
15 | @chunks = Array(ExtendedString).new
16 | end
17 |
18 | def chunks() Array(ExtendedString)
19 | parser = Hydra::StringParser.new(@string)
20 | parser.parse!
21 | parser.chunks
22 | end
23 |
24 | def stripped() String
25 | chunks.map { |i| i.string }.join("")
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/src/hydra/filter.cr:
--------------------------------------------------------------------------------
1 | require "./grid"
2 |
3 | module Hydra
4 | class Filter
5 | def self.apply(grid : Grid) : Grid
6 | grid
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/src/hydra/grid.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class Grid(T)
3 | def initialize(x : Int32, y : Int32)
4 | @x = x
5 | @y = y
6 | @map = Array(Array(T)).new
7 | clear
8 | end
9 |
10 | def clear
11 | fill_with(T.new)
12 | end
13 |
14 | def [](x : Int32, y : Int32) : T | Nil
15 | return nil unless @map[x]?
16 | return nil unless @map[x][y]?
17 | @map[x][y]
18 | end
19 |
20 | def []=(x : Int32, y : Int32, value : T)
21 | return nil if (x > @map.size - 1)
22 | return nil if (y > @map[x].size - 1)
23 | @map[x][y] = value
24 | end
25 |
26 | def dump
27 | @map.map { |row| row.join("") }.join("\n")
28 | end
29 |
30 | def fill_with(item : T)
31 | map = Array(Array(T)).new
32 | @x.times do |i|
33 | map << Array(T).new
34 | @y.times do
35 | map[i] << item.dup
36 | end
37 | end
38 | @map = map
39 | end
40 |
41 | def lines
42 | @map
43 | end
44 |
45 | def each(&block)
46 | @map.each_with_index do |row, x|
47 | row.each_with_index do |item, y|
48 | yield item, x, y
49 | end
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/src/hydra/keypress.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class Keypress
3 | MAPPING = {
4 | 1 => "ctrl-a",
5 | 3 => "ctrl-c",
6 | 5 => "ctrl-e",
7 | 13 => "enter",
8 | 24 => "ctrl-x",
9 | 27 => "escape",
10 | 127 => "backspace",
11 | 338 => "keypress.page_down",
12 | 339 => "keypress.page_up",
13 | }
14 |
15 | getter :name
16 | getter :char
17 | @ctrl_pressed : Bool
18 | @char : String
19 | @name : String
20 |
21 | def initialize(key : UInt32)
22 | if MAPPING[key]?
23 | @name = MAPPING[key]
24 | @char = ""
25 | else
26 | @name = key.chr.to_s
27 | @char = key.chr.to_s
28 | end
29 | @ctrl_pressed = (@name =~ /^ctrl-/ ? true : false)
30 | end
31 |
32 | def ctrl_pressed?() : Bool
33 | @ctrl_pressed
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/src/hydra/list.cr:
--------------------------------------------------------------------------------
1 | require "./element"
2 |
3 | module Hydra
4 | class List < Element
5 | getter :selected
6 |
7 | NONE_SELECTED = -1
8 |
9 | def initialize(id : String, options = Hash(Symbol, String).new)
10 | super
11 | @width = options[:width]? ? options[:width].to_i : 50
12 | @height = options[:height]? ? options[:height].to_i : 10
13 | @items = Array(ExtendedString).new
14 | @scroll_index = 0
15 | @selected = NONE_SELECTED
16 | end
17 |
18 | def content() Hydra::ExtendedString
19 | lower_bound = @scroll_index * -1
20 | upper_bound = lower_bound + inner_height - 1
21 | items = Array(ExtendedString).new
22 | @items[lower_bound..upper_bound].each_with_index do |item, index|
23 | if index - @scroll_index == @selected
24 | item = ExtendedString.new("#{item.string}")
25 | end
26 | items << item
27 | end
28 | res = add_box(items)
29 | Hydra::ExtendedString.new(res)
30 | end
31 |
32 | def add_item(item)
33 | @items << ExtendedString.new(item)
34 | if @items.size == 1
35 | select_first
36 | end
37 | end
38 |
39 | def select_first
40 | @selected = 0
41 | end
42 |
43 | def change_item(index, item)
44 | if @items[index]?
45 | @items[index] = ExtendedString.new(item)
46 | end
47 | end
48 |
49 | def scroll(value : Int32)
50 | @scroll_index += value
51 | end
52 |
53 | def name
54 | # TODO: What is this?
55 | "logbox"
56 | end
57 |
58 | def clear
59 | @items.clear
60 | end
61 |
62 | def select_item(index : Int32)
63 | @selected = index
64 | end
65 |
66 | def value() String
67 | return "" if none_selected?
68 | @items[@selected].string
69 | end
70 |
71 | def none_selected?() Bool
72 | @selected == NONE_SELECTED
73 | end
74 |
75 | def min_scroll_index
76 | inner_height - @items.size
77 | end
78 |
79 | def can_select_up?() Bool
80 | @selected > 0
81 | end
82 |
83 | def can_select_down?() Bool
84 | @selected < @items.size - 1
85 | end
86 |
87 | def select_down
88 | select_item(@selected + 1)
89 | scroll(-1) if can_scroll_down?
90 | end
91 |
92 | def select_up
93 | select_item(@selected - 1)
94 | scroll(1) if can_scroll_up?
95 | end
96 |
97 | def can_scroll_up?() Bool
98 | return false if @items.size <= inner_height
99 | @scroll_index < 0
100 | end
101 |
102 | def can_scroll_down?() Bool
103 | return false if @items.size <= inner_height
104 | @scroll_index > min_scroll_index
105 | end
106 |
107 | def inner_height() Int32
108 | @height - 2 # 2 borders
109 | end
110 |
111 | def add_box(content)
112 | res = "┌" + "─" + @label.ljust(@width - 3, '─') + (can_scroll_up? ? "↑" : "┐") + "\n"
113 | content.each do |item|
114 | pad = item.string.size - item.stripped.size
115 | res += "│" + item.string.ljust(@width - 2 + pad) + "│\n"
116 | end
117 | (inner_height - content.size).times do
118 | res += "│" + "".ljust(@width - 2) + "│\n"
119 | end
120 | res += "└" + "─" * (@width - 2) + (can_scroll_down? ? "↓" : "┘")
121 | res
122 | end
123 |
124 | def trigger(behavior : String, payload = Hash(Symbol, String).new)
125 | case behavior
126 | when "scroll_up"
127 | scroll(1) if can_scroll_up?
128 | when "scroll_down"
129 | scroll(-1) if can_scroll_down?
130 | when "select_up"
131 | select_up if can_select_up?
132 | when "select_down"
133 | select_down if can_select_down?
134 | when "add_item"
135 | add_item payload["item"].to_s
136 | when "change_item"
137 | change_item payload["index"].to_i, payload["item"]
138 | else
139 | super
140 | end
141 | end
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/src/hydra/logbox.cr:
--------------------------------------------------------------------------------
1 | require "./element"
2 |
3 | module Hydra
4 | class Logbox < Element
5 | def initialize(id : String, options = Hash(Symbol, String).new)
6 | super
7 | @width = options[:width]? ? options[:width].to_i : 50
8 | @height = options[:height]? ? options[:height].to_i : 10
9 | @messages = Array(ExtendedString).new
10 | @scroll_index = 0
11 | end
12 |
13 | def content() Hydra::ExtendedString
14 | a = 0
15 | if @messages.size > inner_height
16 | a = @messages.size - inner_height - @scroll_index
17 | end
18 | res = add_box(@messages[a..@messages.size - 1 - @scroll_index])
19 | Hydra::ExtendedString.new(res)
20 | end
21 |
22 | def add_message(message)
23 | @messages << ExtendedString.new(message)
24 | end
25 |
26 | def scroll(value : Int32)
27 | @scroll_index += value
28 | end
29 |
30 | def name
31 | "logbox"
32 | end
33 |
34 | def max_scroll_index
35 | @messages.size - inner_height
36 | end
37 |
38 | def can_scroll_up?() Bool
39 | return false unless @messages.size > inner_height
40 | @scroll_index < max_scroll_index
41 | end
42 |
43 | def can_scroll_down?() Bool
44 | return false unless @messages.size > inner_height
45 | @scroll_index > 0
46 | end
47 |
48 | def inner_height() Int32
49 | @height - 2 # 2 borders
50 | end
51 |
52 | def add_box(content)
53 | res = "┌" + "─" + @label.ljust(@width - 3, '─') + (can_scroll_up? ? "↑" : "┐") + "\n"
54 | content.each do |item|
55 | pad = item.string.size - item.stripped.size
56 | res += "│" + item.string.ljust(@width - 2 + pad) + "│\n"
57 | end
58 | (inner_height - content.size).times do
59 | res += "│" + "".ljust(@width - 2) + "│\n"
60 | end
61 | res += "└" + "─" * (@width - 2) + (can_scroll_down? ? "↓" : "┘")
62 | res
63 | end
64 |
65 | def trigger(behavior : String, payload = Hash(Symbol, String).new)
66 | case behavior
67 | when "scroll_up"
68 | scroll(1) if can_scroll_up?
69 | when "scroll_down"
70 | scroll(-1) if can_scroll_down?
71 | when "add_message"
72 | add_message payload["message"].to_s
73 | end
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/src/hydra/prompt.cr:
--------------------------------------------------------------------------------
1 | require "./element"
2 |
3 | module Hydra
4 | class Prompt < Element
5 | def content() Hydra::ExtendedString
6 | ExtendedString.new(box_content(@value))
7 | end
8 |
9 | private def box_content(content)
10 | if content.size > (width - 2)
11 | content = "…" + content[-(width - 3)..-1]
12 | end
13 | top_bar = "─" + @label.ljust(width - 3, '─')
14 | res = "┌" + top_bar + "┐\n"
15 | res += "│" + ExtendedString.escape(content.ljust(width - 2)) + "│\n"
16 | res += "└" + "─" * (width - 2) + "┘"
17 | res
18 | end
19 |
20 | def append(string : String)
21 | @value += string
22 | end
23 |
24 | def remove_last
25 | return if @value.size == 0
26 | @value = @value[0..-2]
27 | end
28 |
29 | def value
30 | @value
31 | end
32 |
33 | def clear
34 | @value = ""
35 | end
36 |
37 | def trigger(behavior : String, payload = Hash(Symbol, String).new)
38 | if behavior == "append"
39 | append(payload[:char])
40 | elsif behavior == "remove_last"
41 | remove_last
42 | elsif behavior == "clear"
43 | clear
44 | else
45 | super
46 | end
47 | end
48 |
49 | def on_register(event_hub : Hydra::EventHub)
50 | event_hub.bind(id, "keypress.*") do |eh, event|
51 | keypress = event.keypress
52 | if keypress
53 | if keypress.char != ""
54 | eh.trigger(id, "append", { :char => keypress.char })
55 | false
56 | elsif keypress.name == "backspace"
57 | eh.trigger(id, "remove_last")
58 | false
59 | else
60 | true
61 | end
62 | else
63 | true
64 | end
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/src/hydra/screen.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class Screen
3 | def initialize
4 | end
5 |
6 | def getch() Keypress
7 | nil
8 | end
9 |
10 | def update(grid : Grid(Cell))
11 | end
12 |
13 | def close
14 | end
15 |
16 | def width
17 | 0
18 | end
19 |
20 | def height
21 | 0
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/src/hydra/state.cr:
--------------------------------------------------------------------------------
1 | module Hydra
2 | class State < Hash(String, String)
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/src/hydra/string_parser.cr:
--------------------------------------------------------------------------------
1 | require "./extended_string"
2 |
3 | module Hydra
4 | class StringParser
5 | getter :chunks
6 |
7 | TAG_START_CHAR = "<"
8 | TAG_END_CHAR = ">"
9 | TAG_CLOSE_CHAR = "/"
10 | ESCAPE_CHAR = "\\"
11 |
12 | def initialize(string : String)
13 | @in_tag = false
14 | @closing_tag = false
15 | @tag = ""
16 | @current_chunk = ExtendedString.new("")
17 | @chunks = Array(ExtendedString).new
18 | @string = string
19 | @escape = false
20 | end
21 |
22 | def parse!
23 | each_char do |char|
24 | if char == ESCAPE_CHAR
25 | @escape = true
26 | next
27 | end
28 | if char == TAG_START_CHAR
29 | if @escape
30 | @escape = false
31 | else
32 | start_tag
33 | next
34 | end
35 | end
36 | if @escape
37 | @escape = false
38 | if @in_tag
39 | @tag += ESCAPE_CHAR
40 | else
41 | @current_chunk.string += ESCAPE_CHAR
42 | end
43 | end
44 | case char
45 | when TAG_CLOSE_CHAR
46 | if in_blank_tag?
47 | @closing_tag = true
48 | next
49 | end
50 | when TAG_END_CHAR
51 | if @in_tag
52 | end_tag
53 | next
54 | end
55 | end
56 | if @in_tag
57 | @tag += char
58 | else
59 | @current_chunk.string += char
60 | end
61 | end
62 | if @escape
63 | @escape = false
64 | if @in_tag
65 | @tag += ESCAPE_CHAR
66 | else
67 | @current_chunk.string += ESCAPE_CHAR
68 | end
69 | end
70 | store_current_chunk
71 | end
72 |
73 | private def each_char(&block)
74 | @string.split("").each do |char|
75 | yield char
76 | end
77 | end
78 |
79 | private def start_tag()
80 | store_current_chunk
81 | new_chunk = ExtendedString.new("")
82 | new_chunk.tags = @current_chunk.tags.dup
83 | @current_chunk = new_chunk
84 | @in_tag = true
85 | end
86 |
87 | private def end_tag()
88 | if @closing_tag
89 | @current_chunk.tags.pop if @current_chunk.tags.size > 0 && @current_chunk.tags.last == @tag
90 | else
91 | @current_chunk.tags << @tag
92 | end
93 | @tag = ""
94 | @in_tag = false
95 | @closing_tag = false
96 | end
97 |
98 | private def in_blank_tag?() Bool
99 | @in_tag && @tag == ""
100 | end
101 |
102 | private def store_current_chunk
103 | @chunks << @current_chunk unless @current_chunk.string == ""
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/src/hydra/terminal_screen.cr:
--------------------------------------------------------------------------------
1 | require "./screen"
2 | require "termbox"
3 | require "./grid"
4 | require "./cell"
5 | require "./color"
6 |
7 | module Hydra
8 | class TerminalScreen < Screen
9 | def initialize
10 | super
11 | @win = Termbox::Window.new
12 |
13 | # Use 256 color mode
14 | @win.set_output_mode(Termbox::OUTPUT_256)
15 |
16 | @foreground_color = Color.new("white")
17 | @background_color = Color.new("black")
18 | @win.set_primary_colors(@foreground_color.index, @background_color.index)
19 | end
20 |
21 | def update(grid : Grid(Cell))
22 | @win.clear
23 | grid.each do |cell, x, y|
24 | foreground_color = @foreground_color
25 | background_color = @background_color
26 | cell.tags.each do |tag|
27 | color = color_from_tag(tag)
28 | if color
29 | foreground_color = color
30 | end
31 | end
32 | if cell.tags.includes?("inverted")
33 | color = foreground_color
34 | foreground_color = background_color
35 | background_color = color
36 | end
37 | @win.write_string(Termbox::Position.new(y, x), cell.char, foreground_color.index, background_color.index)
38 | end
39 | @win.render
40 | end
41 |
42 | def getch() Keypress
43 | event = @win.peek(1)
44 | if event.type == Termbox::EVENT_KEY
45 | if event.ch > 0
46 | Keypress.new(event.ch)
47 | elsif event.key > 0
48 | Keypress.new(UInt32.new(event.key))
49 | end
50 | else
51 | return nil
52 | end
53 | end
54 |
55 | def close
56 | @win.shutdown
57 | end
58 |
59 | def height
60 | @win.height
61 | end
62 |
63 | def width
64 | @win.width
65 | end
66 |
67 | def color_from_tag(tag : String) String
68 | if md = tag.match(/\A(#{Color::COLORS.keys.join("|")})-fg\Z/)
69 | return Color.new(md[1])
70 | end
71 | nil
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/src/hydra/text.cr:
--------------------------------------------------------------------------------
1 | require "./element"
2 |
3 | module Hydra
4 | class Text < Element
5 | def initialize(id : String, options = Hash(Symbol, String).new)
6 | super
7 | autosize! if options[:autosize]? && options[:autosize] == "true"
8 | end
9 | def autosize!
10 | @width = (extended_value.stripped.split("\n") + [@label + "**"]).map { |s| s.size }.max + 2
11 | @height = extended_value.stripped.split("\n").size + 2
12 | end
13 |
14 | def content() Hydra::ExtendedString
15 | Hydra::ExtendedString.new(box_content(@value))
16 | end
17 |
18 | def extended_value
19 | Hydra::ExtendedString.new(@value)
20 | end
21 |
22 | private def box_content(content)
23 | x = width
24 | res = "┌" + "─" + @label.ljust(x - 3, '─') + "┐\n"
25 | lines = content.split("\n")
26 | lines.each do |line|
27 | extended_line = Hydra::ExtendedString.new(line)
28 | pad = line.size - extended_line.stripped.size
29 | res += "│" + line.ljust(x - 2 + pad, ' ') + "│\n"
30 | end
31 | (height - lines.size - 2).times do
32 | res += "│" + " " * (x - 2) + "│\n"
33 | end
34 | res += "└" + "─" * (x - 2) + "┘"
35 | res
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/src/hydra/view.cr:
--------------------------------------------------------------------------------
1 | require "./grid"
2 | require "./cell"
3 | require "./element"
4 | require "./filter"
5 | require "./border_filter"
6 |
7 | module Hydra
8 | class View
9 | property :filters
10 | getter :height, :width, :grid
11 | def initialize(height : Int32, width : Int32)
12 | @height = height
13 | @width = width
14 | @grid = Grid(Hydra::Cell).new(height, width)
15 | @filters = Array(Filter.class).new
16 | end
17 |
18 | def clear
19 | @grid = Grid(Hydra::Cell).new(@height, @width)
20 | end
21 |
22 | def print(x : Int, y : Int, text : String, tags : Array(String))
23 | text.split("").each_with_index do |char, i|
24 | cell = Hydra::Cell.new(char, tags)
25 | @grid[x, y + i] = cell
26 | end
27 | end
28 |
29 | def render(elements : Array(Element), state = Hash(String, String).new)
30 | clear
31 | elements.sort {|a, b| a.z_index <=> b.z_index }.each do |el|
32 | if el.template != ""
33 | el.value = el.template
34 | state.each do |key, value|
35 | el.value = el.value.sub("{{#{key}}}", value)
36 | end
37 | end
38 | render_element(el) if el.visible
39 | end
40 | @filters.reduce(@grid) do |memo, filter|
41 | filter.apply(memo)
42 | end
43 | end
44 |
45 | # The supported values for the position attribute are:
46 | # * center
47 | # * bottom-left
48 | def render_element(element : Element)
49 | x, y = 0, 0
50 | if element.position == "center"
51 | x = (@height.to_f / 2 - element.height.to_f / 2).floor.to_i
52 | y = (@width.to_f / 2 - element.width.to_f / 2).floor.to_i
53 | elsif element.position == "bottom-left"
54 | x = @height - element.height
55 | else
56 | x, y = element.position.split(":").map(&.to_i)
57 | end
58 |
59 | i = 0
60 | j = 0
61 |
62 | element.content.chunks.each do |chunk|
63 | lines = chunk.string.split("\n")
64 | lines.each_with_index do |l, idx|
65 | if idx > 0
66 | i += 1
67 | j = 0
68 | end
69 | print(x + i, y + j, l, chunk.tags)
70 | j += l.size
71 | end
72 | end
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------