├── .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 | --------------------------------------------------------------------------------