├── bin └── .keep ├── test-auto ├── lib ├── hello │ ├── output │ │ ├── snapshot.crysterm │ │ └── snapshot.blessed │ ├── main.cr │ └── main.js ├── run-blessed.sh ├── run-crysterm.sh └── README.md ├── src ├── fonts │ ├── AUTHORS │ └── LICENSE ├── widget │ ├── listtable.cr │ ├── scrollable_box.cr │ ├── scrollable_text.cr │ ├── label.cr │ ├── radioset.cr │ ├── box.cr │ ├── vline.cr │ ├── hline.cr │ ├── menu.cr │ ├── pine │ │ ├── status_bar.cr │ │ └── header_bar.cr │ ├── button.cr │ ├── line.cr │ ├── overlayimage.cr │ ├── log.cr │ ├── radiobutton.cr │ ├── textbox.cr │ ├── input.cr │ ├── loading.cr │ ├── checkbox.cr │ ├── prompt.cr │ ├── message.cr │ ├── question.cr │ ├── bigtext.cr │ └── progressbar.cr ├── version.cr ├── mixin │ ├── name.cr │ ├── data.cr │ ├── uid.cr │ ├── pos.cr │ ├── instances.cr │ ├── style.cr │ └── children.cr ├── ext.cr ├── colors.cr ├── widget_index.cr ├── widget_visibility.cr ├── screen_rows.cr ├── screen_decoration.cr ├── widgets.cr ├── widget_decoration.cr ├── macros.cr ├── crysterm.cr ├── widget_children.cr ├── widget_interaction.cr ├── screen_resize.cr ├── widget_screenshot.cr ├── screen_screenshot.cr ├── screen_children.cr ├── widget_label.cr ├── action.cr ├── screen_angles.cr ├── helpers.cr ├── screen_cursor.cr ├── screen_attributes.cr ├── screen_focus.cr ├── event.cr ├── screen_rendering.cr ├── widget.cr └── screen_interaction.cr ├── spec ├── spec_helper.cr └── crysterm_spec.cr ├── screenshots ├── layout.png ├── shadow.png ├── widget.dia ├── widget.png ├── widget.xcf ├── 2020-01-29-1.gif └── transparency.png ├── documentation └── README.md ├── .editorconfig ├── .gitignore ├── patches ├── README.md └── wrap_content.patch ├── test ├── helpers.cr ├── widget-valign.cr ├── widget-bigtext.cr ├── widget-insert.cr ├── widget-autopad.cr ├── widget-layout.cr.blessed-patch ├── widget-textarea.cr ├── widget-padding.cr ├── widget-line.cr ├── widget-bigtext-clock.cr ├── widget-list.cr ├── widget-loading.cr ├── widget-log.cr ├── widget-exit.cr ├── widget-prompt.cr ├── widget-scrollable-boxes.cr └── widget-layout.cr ├── small-tests ├── screen-padding.cr ├── pine.cr ├── label.cr ├── action.cr ├── parents.cr ├── textarea-with-scroll.cr ├── button.cr ├── screen.cr ├── elements.cr ├── checkbox.cr ├── radiobutton.cr ├── focus.cr ├── shadow.cr └── tags.cr ├── misc ├── core_application │ └── options.cr └── question.cr ├── shard.yml ├── examples ├── colorchart.cr ├── hello2.cr ├── hello.cr ├── chat.js ├── chat.cr └── tech-demo.cr ├── blessed.patch ├── .github └── workflows │ └── ci.yml └── CRYSTAL-WISHLIST.md /bin/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-auto/lib: -------------------------------------------------------------------------------- 1 | ../lib -------------------------------------------------------------------------------- /src/fonts/AUTHORS: -------------------------------------------------------------------------------- 1 | Dimitar Zhekov 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/crysterm" 3 | require "../src/helpers" 4 | -------------------------------------------------------------------------------- /screenshots/layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/layout.png -------------------------------------------------------------------------------- /screenshots/shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/shadow.png -------------------------------------------------------------------------------- /screenshots/widget.dia: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/widget.dia -------------------------------------------------------------------------------- /screenshots/widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/widget.png -------------------------------------------------------------------------------- /screenshots/widget.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/widget.xcf -------------------------------------------------------------------------------- /screenshots/2020-01-29-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/2020-01-29-1.gif -------------------------------------------------------------------------------- /screenshots/transparency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystallabs/crysterm/HEAD/screenshots/transparency.png -------------------------------------------------------------------------------- /test-auto/hello/output/snapshot.crysterm: -------------------------------------------------------------------------------- 1 | ┌─────────────┐ 2 | │Hello, World!│ 3 | └─────────────┘ 4 | -------------------------------------------------------------------------------- /test-auto/hello/output/snapshot.blessed: -------------------------------------------------------------------------------- 1 | ┌─────────────┐ 2 | │Hello, World!│ 3 | └─────────────┘ 4 | 5 | -------------------------------------------------------------------------------- /src/widget/listtable.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | class ListTable < Widget 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /src/widget/scrollable_box.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | class ScrollableBox < Box 4 | @scrollable = true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /documentation/README.md: -------------------------------------------------------------------------------- 1 | # Crysterm docs 2 | 3 | Various development-oriented documentation. 4 | 5 | Explanations of how things work in Blessed and/or how those concepts have been applied in Crysterm. 6 | -------------------------------------------------------------------------------- /src/version.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | VERSION_MAJOR = 0 3 | VERSION_MINOR = 1 4 | VERSION_REVISION = 0 5 | VERSION = {VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION}.join '.' 6 | end 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/widget/scrollable_text.cr: -------------------------------------------------------------------------------- 1 | require "./scrollable_box" 2 | 3 | module Crysterm 4 | class Widget 5 | class ScrollableText < ScrollableBox 6 | @always_scroll = true 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | /screenshots/ 7 | /misc/ 8 | 9 | # Libraries don't need dependency lock 10 | # Dependencies will be locked in applications that use them 11 | /shard.lock 12 | -------------------------------------------------------------------------------- /patches/README.md: -------------------------------------------------------------------------------- 1 | # Possible patches 2 | 3 | Collection of patches which do work at time of creation, but are alternative implementations or do not have the expected benefits. 4 | 5 | Any patch comments are included at the top of each patch. 6 | -------------------------------------------------------------------------------- /src/mixin/name.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Mixin 3 | module Name 4 | # Arbitrary widget name. This property exists for user convenience; it is not used by Crysterm. 5 | property name : String? 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/widget/label.cr: -------------------------------------------------------------------------------- 1 | require "./box" 2 | 3 | module Crysterm 4 | class Widget 5 | # Basic read-only label 6 | class Label < Box 7 | @resizable = true 8 | end 9 | 10 | alias Text = Label 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/mixin/data.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module Crysterm 4 | module Mixin 5 | module Data 6 | # Arbitrary extra/external content attached to widget as YAML 7 | property data : YAML::Any? 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/widget/radioset.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Radio set element 4 | class RadioSet < Box 5 | # D O: 6 | # Possibly inherit parent's style. 7 | # @style = @parent.style 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/ext.cr: -------------------------------------------------------------------------------- 1 | class ::String 2 | # SGR Attribute of a String 3 | # 4 | # XXX Needed only because of `CLines < Array(String)`. If `CLines` used 5 | # a different type of elements in its array, this extension to built-in 6 | # type could be avoided. 7 | property attr = [] of Int32 8 | end 9 | -------------------------------------------------------------------------------- /test/helpers.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | extend Helpers 5 | 6 | puts parse_tags "{red-fg}This should be red.{/red-fg}" 7 | puts parse_tags "{green-bg}This should have a green background.{/green-bg}" 8 | 9 | sleep 2.seconds 10 | 11 | exit 12 | end 13 | -------------------------------------------------------------------------------- /test-auto/run-blessed.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set +x 4 | 5 | for p in `find -maxdepth 1 -type d | tail -n +2 `; do 6 | if test -e "$p/main.js"; then 7 | echo "$p" 8 | mkdir -p "$p/output" 9 | node "$p/main.js" --test-auto 2>"$p/output/snapshot.blessed" 10 | fi 11 | done 12 | -------------------------------------------------------------------------------- /test-auto/run-crysterm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set +x 4 | 5 | for p in `find -maxdepth 1 -type d | tail -n +2 `; do 6 | if test -e "$p/main.cr"; then 7 | echo "$p" 8 | mkdir -p "$p/output" 9 | crystal run "$p/main.cr" -- --test-auto 2>"$p/output/snapshot.crysterm" 10 | fi 11 | done 12 | -------------------------------------------------------------------------------- /src/widget/box.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Box element 4 | class Box < Widget 5 | # XXX Why this must be here, even though it's set in src/widget_size.cr? 6 | # Check e.g. small-tests/shadow.cr with and without this option here. 7 | @resizable = false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/widget/vline.cr: -------------------------------------------------------------------------------- 1 | require "./line" 2 | 3 | module Crysterm 4 | class Widget 5 | # Vertical line 6 | class VLine < Line 7 | @orientation = :vertical 8 | 9 | def initialize(**line) 10 | super @orientation, **line 11 | end 12 | end 13 | 14 | alias Vline = VLine 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/widget/hline.cr: -------------------------------------------------------------------------------- 1 | require "./line" 2 | 3 | module Crysterm 4 | class Widget 5 | # Horizontal line 6 | class HLine < Line 7 | @orientation = :horizontal 8 | 9 | def initialize(**line) 10 | super @orientation, **line 11 | end 12 | end 13 | 14 | alias Hline = HLine 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /small-tests/screen-padding.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class MyProg 4 | include Crysterm 5 | 6 | s = Screen.new padding: 4 7 | 8 | b = Widget::Box.new width: "100%", height: "100%", style: Style.new(border: true) 9 | s.append b 10 | 11 | # When q is pressed, exit the demo. 12 | s.on(Event::KeyPress) do |e| 13 | if e.char == 'q' || e.key == Tput::Key::CtrlQ 14 | s.destroy 15 | exit 16 | end 17 | end 18 | 19 | s.exec 20 | end 21 | -------------------------------------------------------------------------------- /test-auto/hello/main.cr: -------------------------------------------------------------------------------- 1 | require "../../src/crysterm" 2 | 3 | include Crysterm 4 | 5 | s = Screen.new 6 | 7 | w = Widget::Box.new \ 8 | parent: s, 9 | top: 0, 10 | left: 0, 11 | resizable: true, 12 | content: "Hello, World!", 13 | parse_tags: false, 14 | style: Style.new(fg: "yellow", bg: "blue", border: true) 15 | 16 | s.on(Event::KeyPress) { |_| exit } 17 | 18 | s.on(Event::Rendered) { 19 | if ARGV.includes? "--test-auto" 20 | STDERR.puts w.snapshot 21 | exit 22 | end 23 | } 24 | 25 | s.render 26 | 27 | sleep 28 | -------------------------------------------------------------------------------- /src/mixin/uid.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Mixin 3 | module Uid 4 | @@uid : Atomic(Int32) = Atomic.new 0i32 5 | 6 | # Returns next widget UID. 7 | # 8 | # UIDs are generated sequentially, with ID sequence kept in an int32. 9 | def self.next_uid : Int32 10 | @@uid.add 1 11 | end 12 | 13 | # Unique ID. Auto-incremented. 14 | # 15 | # NOTE This is an instance var; setting it to the value of `@@uid` happens in includers. 16 | property uid : Int32 = next_uid 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/widget-valign.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | s = Screen.new 5 | 6 | b = Widget::Box.new( 7 | top: "center", 8 | left: "center", 9 | width: "50%", 10 | height: 5, 11 | align: Tput::AlignFlag::Center, 12 | content: "Foobar.", 13 | style: Style.new(border: true) 14 | ) 15 | 16 | s.append b 17 | 18 | s.on(Event::KeyPress) do |e| 19 | # STDERR.puts e.inspect 20 | if e.char == 'q' 21 | # e.accept 22 | s.destroy 23 | exit 24 | end 25 | end 26 | 27 | s.exec 28 | end 29 | -------------------------------------------------------------------------------- /misc/core_application/options.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | abstract class CoreApplication 3 | class Options 4 | Toka.mapping({ 5 | colors: { 6 | # short: ['c'], 7 | # long: ["colors"], 8 | type: Int32?, 9 | default: nil, 10 | description: "Number of colors to use. If left at nil, will be autodetected.", 11 | }, 12 | }, { 13 | banner: Crysterm::CoreApplication.about, 14 | footer: "", 15 | help: true, 16 | color: true, 17 | }) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/widget/menu.cr: -------------------------------------------------------------------------------- 1 | require "../action" 2 | 3 | module Crysterm 4 | class Widget 5 | class Menu < Widget 6 | property title : String = "" 7 | 8 | property actions = [] of Action 9 | 10 | def initialize(**widget) 11 | super **widget 12 | end 13 | 14 | def initialize(@title, **widget) 15 | super **widget 16 | end 17 | 18 | def <<(action : Action) 19 | @actions << action unless @actions.includes? action 20 | end 21 | 22 | def >>(action : Action) 23 | @actions.delete action 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/colors.cr: -------------------------------------------------------------------------------- 1 | require "term_colors" 2 | 3 | module Crysterm 4 | # Color-related functionality. 5 | # 6 | # At the moment this just imports methods from TermColors as this module's methods. 7 | # 8 | # Term-colors shard for the moment supports outputting up to 256 colors. 9 | # Adding TrueColor (16M colors) is on the TODO. 10 | # 11 | # In the future, when TrueColor support is added, everything in Crystem will be 12 | # adjusted to work with 16M colors without any conversion, and colors will be scaled 13 | # down to 256/16/8/2/1 only when necessary. 14 | module Colors 15 | extend ::TermColors 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test-auto/hello/main.js: -------------------------------------------------------------------------------- 1 | var blessed = require('../../blessed') 2 | , screen; 3 | 4 | s = blessed.screen({}); 5 | 6 | w = blessed.box({ 7 | parent: s, 8 | top: 0, 9 | left: 0, 10 | shrink: true, 11 | content: 'Hello, World!', 12 | tags: false, 13 | style: { 14 | fg: 'yellow', 15 | bg: 'blue', 16 | }, 17 | border: 'line', 18 | }); 19 | 20 | s.on('keypress', function() { 21 | return s.destroy(); 22 | }); 23 | 24 | s.on('render', function() { 25 | if(process.argv.includes('--test-auto')) { 26 | scr = w.snapshot(); 27 | console.error(scr); 28 | process.exit() 29 | } 30 | }); 31 | 32 | s.render(); 33 | -------------------------------------------------------------------------------- /small-tests/pine.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | s = Screen.new 5 | 6 | hbar = Widget::Pine::HeaderBar.new title_content: "ALPINE 2.20", section_content: "MAIN MENU", subsection_content: "Folder: INDEX", info_content: "37 Messages" 7 | 8 | cbar = Widget::Box.new left: "center", top: "100%-5", content: %q{For exit press "?"} 9 | 10 | sbar = Widget::Pine::StatusBar.new top: "100%-4" 11 | 12 | s.append hbar, sbar, cbar 13 | 14 | s.on(Crysterm::Event::KeyPress) do 15 | s.destroy 16 | exit 17 | end 18 | 19 | s.render 20 | 21 | sleep 2.seconds 22 | 23 | sbar.status.set_content "[Already at {underline}bottom{/underline} of list]" 24 | 25 | s.exec 26 | end 27 | -------------------------------------------------------------------------------- /src/widget/pine/status_bar.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | module Pine 4 | class StatusBar < Widget::Box 5 | property status 6 | 7 | def initialize( 8 | height h = 1, width w = "100%", 9 | status_content = "", 10 | status : Widget? = nil, 11 | style = Style.new, 12 | **layout 13 | ) 14 | super **layout, style: style, width: w, height: h 15 | 16 | style2 = style.dup 17 | style2.inverse = true 18 | 19 | @status = Widget::Box.new height: h, left: "center", style: style2, content: status_content 20 | 21 | append @status 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/widget-bigtext.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | 6 | s = Screen.new optimization: OptimizationFlag::SmartCSR 7 | 8 | b = Widget::BigText.new \ 9 | content: "Hello", 10 | # parse_tags: true, 11 | resizable: true, 12 | width: "80%", 13 | 14 | style: Style.new( 15 | fg: "red", 16 | bg: "blue", 17 | bold: false, 18 | # fchar: ' ', 19 | char: '\u2592', 20 | border: BorderType::Line, 21 | ) 22 | 23 | s.append b 24 | b.focus 25 | s.render 26 | 27 | s.on(Event::KeyPress) do |e| 28 | e.accept 29 | if e.char == 'q' 30 | s.destroy 31 | exit 32 | end 33 | end 34 | 35 | s.exec 36 | end 37 | -------------------------------------------------------------------------------- /small-tests/label.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::Tab, ::Tput::Key::ShiftTab, ::Tput::Key::CtrlQ] 9 | 10 | label = Widget::Label.new content: "This is a label.", style: Style.new border: true 11 | 12 | s.append label 13 | 14 | s.on(Crysterm::Event::KeyPress) do |e| 15 | if e.key == ::Tput::Key::CtrlQ 16 | s.destroy 17 | exit 18 | elsif e.key == ::Tput::Key::Tab 19 | s.focus_next 20 | elsif e.key == ::Tput::Key::ShiftTab 21 | s.focus_previous 22 | end 23 | s.render 24 | end 25 | 26 | s.exec 27 | end 28 | end 29 | 30 | X.new 31 | -------------------------------------------------------------------------------- /test/widget-insert.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | s = Screen.new 5 | 6 | b = Widget::Box.new( 7 | style: Style.new( 8 | bg: "blue", 9 | ), 10 | height: 5, 11 | top: "center", 12 | left: 0, 13 | width: 12, 14 | content: "{yellow-fg}line{/yellow-fg}{|}1" 15 | ) 16 | 17 | s.append b 18 | 19 | s.on(Event::KeyPress) do |e| 20 | # STDERR.puts e.inspect 21 | if e.char == 'q' 22 | # e.accept 23 | s.destroy 24 | exit 25 | end 26 | end 27 | 28 | s.render 29 | 30 | b.insert_bottom "{yellow-fg}line{/yellow-fg}{|}2" 31 | b.insert_top "{yellow-fg}line{/yellow-fg}{|}0" 32 | 33 | s.render 34 | 35 | sleep 2.seconds 36 | 37 | b.delete_top 38 | 39 | s.render 40 | 41 | s.exec 42 | end 43 | -------------------------------------------------------------------------------- /test/widget-autopad.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | s = Screen.new optimization: OptimizationFlag::SmartCSR 5 | 6 | b = Widget::Box.new( 7 | top: "center", 8 | left: "center", 9 | width: 20, 10 | height: 10, 11 | style: Style.new(border: true), 12 | ) 13 | 14 | # Must add the Widget to screen in this way for the moment 15 | s.append b 16 | 17 | b2 = Widget::Box.new( 18 | parent: b, 19 | top: 0, 20 | left: 0, 21 | width: 10, 22 | height: 5, 23 | style: Style.new(border: true), 24 | ) 25 | 26 | s.on(Event::KeyPress) do |e| 27 | # STDERR.puts e.inspect 28 | if e.char == 'q' 29 | # e.accept 30 | s.destroy 31 | exit 32 | end 33 | end 34 | 35 | s.render 36 | 37 | s.exec 38 | end 39 | -------------------------------------------------------------------------------- /small-tests/action.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | a = Action.new 5 | 6 | a.on(Event::Hovered) do 7 | p "Action has hovered" 8 | end 9 | 10 | a.on(Event::Triggered) do 11 | p "Action has triggered (1st handler)" 12 | end 13 | 14 | a.on(Event::Triggered) do 15 | p "Action has triggered (2nd handler)" 16 | end 17 | 18 | # Don't do it for now since Menu is a widget, and it implicitly creates 19 | # a Screen, so it switches terminal to alt buffer, hiding 20 | # printed messages. 21 | # m = Widget::Menu.new "Menu1" 22 | # m << a 23 | 24 | a.activate 25 | a.activate(Event::Triggered) 26 | a.activate(Event::Hovered) 27 | 28 | # Not available in Crystal API (to always use #activate instead) 29 | # a.trigger 30 | # a.hover 31 | end 32 | -------------------------------------------------------------------------------- /test/widget-layout.cr.blessed-patch: -------------------------------------------------------------------------------- 1 | diff --git a/test/widget-layout.js b/test/widget-layout.js 2 | index 46e447b..2e58440 100644 3 | --- a/test/widget-layout.js 4 | +++ b/test/widget-layout.js 5 | @@ -145,13 +145,14 @@ var box12 = blessed.box({ 6 | }); 7 | 8 | if (process.argv[2] !== 'grid') { 9 | + sizes = [ 0.2, 1, 0.3, 0.6, 0.3, 0.9, 0.2, 0.75, 0.1, 0.99 ] 10 | for (var i = 0; i < 10; i++) { 11 | blessed.box({ 12 | parent: layout, 13 | // width: i % 2 === 0 ? 10 : 20, 14 | // height: i % 2 === 0 ? 5 : 10, 15 | - width: Math.random() > 0.5 ? 10 : 20, 16 | - height: Math.random() > 0.5 ? 5 : 10, 17 | + width: sizes[i] > 0.5 ? 10 : 20, 18 | + height: sizes[i] > 0.5 ? 5 : 10, 19 | border: 'line', 20 | content: (i + 1 + 12) + '' 21 | }); 22 | -------------------------------------------------------------------------------- /small-tests/parents.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::Tab, ::Tput::Key::ShiftTab, ::Tput::Key::CtrlQ] 9 | 10 | i1 = Widget::TextBox.new \ 11 | width: 10, 12 | height: 3, 13 | top: 6, 14 | left: 6, 15 | content: "Box1", 16 | style: Style.new(fg: "yellow", bg: "red", border: true) 17 | 18 | i2 = Widget::Layout.new width: "100%", height: "100%" 19 | i3 = Widget::Layout.new width: "100%", height: "100%" 20 | 21 | i2.append i1 22 | i3.append i1 23 | 24 | STDERR.puts i1.parent.hash, 25 | i2.children.size, 26 | i3.children.size 27 | 28 | s.destroy 29 | exit 30 | end 31 | end 32 | 33 | X.new 34 | -------------------------------------------------------------------------------- /small-tests/textarea-with-scroll.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::CtrlQ] 9 | 10 | # parent: l, 11 | i = Widget::TextArea.new \ 12 | width: 10, 13 | height: 8, 14 | top: 4, 15 | left: 8, 16 | content: "Kico\n2\n3", # "center", left: "center" #, border: true 17 | style: Style.new(fg: "yellow", bg: "red", border: true), 18 | input_on_focus: true 19 | 20 | s.append i 21 | 22 | s.on(Crysterm::Event::KeyPress) do |e| 23 | if e.char == 'q' || e.key == ::Tput::Key::CtrlQ 24 | s.destroy 25 | exit 26 | end 27 | end 28 | 29 | s.render 30 | 31 | s.exec 32 | end 33 | end 34 | 35 | X.new 36 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crysterm 2 | version: 1.0.0 3 | 4 | authors: 5 | - Davor Ocelic 6 | 7 | crystal: 1.0.0 8 | 9 | license: AGPLv3 10 | 11 | dependencies: 12 | tput: 13 | github: crystallabs/tput 14 | branch: master 15 | crystallabs-helpers: 16 | github: crystallabs/crystallabs-helpers.cr 17 | version: ~> 1.0 18 | term_colors: 19 | github: crystallabs/term_colors 20 | version: ~> 1.0 21 | w3m_image_display: 22 | github: SamualLB/w3mimagedisplay 23 | #i18n: 24 | # github: BrucePerens/i18n 25 | event_handler: 26 | github: crystallabs/event_handler 27 | version: ~> 1.0 28 | gpm: 29 | github: crystallabs/gpm.cr 30 | version: ~> 1.0 31 | 32 | development_dependencies: 33 | ameba: 34 | github: crystal-ameba/ameba 35 | version: ~> 1.6.3 36 | -------------------------------------------------------------------------------- /src/widget/button.cr: -------------------------------------------------------------------------------- 1 | require "./input" 2 | 3 | module Crysterm 4 | class Widget 5 | # Button element 6 | class Button < Input 7 | include EventHandler 8 | 9 | getter value = false 10 | 11 | def initialize(**input) 12 | super **input 13 | 14 | handle Crysterm::Event::KeyPress 15 | handle Crysterm::Event::Click 16 | end 17 | 18 | def press 19 | focus 20 | @value = true 21 | emit Crysterm::Event::Press 22 | @value = false 23 | end 24 | 25 | def on_keypress(e) 26 | if e.char == ' ' || e.key.try(&.==(::Tput::Key::Enter)) 27 | e.accept 28 | press 29 | end 30 | end 31 | 32 | def on_click(e) 33 | # e.accept 34 | press 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /src/widget_index.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Widget's position in the stack (front, back). Render index / order. 4 | 5 | property index = -1 6 | 7 | # Sends widget to front 8 | def front! 9 | set_index -1 10 | end 11 | 12 | # Sends widget to back 13 | def back! 14 | set_index 0 15 | end 16 | 17 | def set_index(index : Int) 18 | return unless parent = @parent 19 | 20 | if index < 0 21 | index = parent.children.size + index 22 | end 23 | 24 | index = Math.max index, 0 25 | index = Math.min index, parent.children.size - 1 26 | 27 | i = parent.children.index self 28 | 29 | return unless i 30 | 31 | parent.children.insert index, parent.children.delete_at i 32 | 33 | true 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /src/mixin/pos.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Mixin 3 | module Pos 4 | # NOTE See what this is for and if it can be unified/integrated into 5 | # something else (or if something else can be removed in favor of this) 6 | # to removal total amount of code. 7 | 8 | # Number of times object was rendered 9 | property renders = 0 10 | 11 | # Absolute left offset. 12 | property aleft : Int32? = nil 13 | 14 | # Absolute top offset. 15 | property atop : Int32? = nil 16 | 17 | # Absolute right offset. 18 | property aright : Int32? = nil 19 | 20 | # Absolute bottom offset. 21 | property abottom : Int32? = nil 22 | 23 | property? scrollable = false 24 | 25 | # Last rendered position 26 | property lpos : LPos? = nil 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/widget-textarea.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::CtrlQ] 9 | 10 | # parent: l, 11 | i = Widget::TextArea.new \ 12 | width: "half", 13 | height: "half", 14 | top: "center", 15 | left: "center", 16 | parse_tags: true, 17 | style: Style.new(bg: "blue", scrollbar: Style.new(bg: "red"), track: Style.new(char: '▒')), 18 | track: true, 19 | input_on_focus: true, 20 | scrollbar: true 21 | 22 | s.append i 23 | 24 | s.on(Crysterm::Event::KeyPress) do |e| 25 | if e.char == 'q' || e.key == ::Tput::Key::CtrlQ 26 | s.destroy 27 | exit 28 | end 29 | end 30 | 31 | s.render 32 | 33 | s.exec 34 | end 35 | end 36 | 37 | X.new 38 | -------------------------------------------------------------------------------- /test/widget-padding.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | s = Screen.new 5 | 6 | b = Widget::Box.new( 7 | style: Style.new( 8 | bg: "red", 9 | # TODO This part is not required in Blessed. See why is it required here and, 10 | # if it makes sense, return the behavior back to be compatible with Blessed. 11 | border: Border.new( 12 | bg: "black" 13 | ), 14 | padding: 2 15 | ), 16 | content: "hello world\nhi", 17 | align: Tput::AlignFlag::Center, 18 | top: "center", 19 | left: "center", 20 | width: 22, 21 | height: 10 22 | ) 23 | 24 | s.append b 25 | 26 | s.on(Event::KeyPress) do |e| 27 | # STDERR.puts e.inspect 28 | if e.char == 'q' 29 | # e.accept 30 | s.destroy 31 | exit 32 | end 33 | end 34 | 35 | s.render 36 | 37 | s.exec 38 | end 39 | -------------------------------------------------------------------------------- /small-tests/button.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new 9 | 10 | i = Widget::Button.new \ 11 | width: 50, 12 | height: 5, 13 | top: 4, 14 | left: 8, 15 | content: "It's in focus, press ENTER", 16 | align: ::Tput::AlignFlag::Center, 17 | style: Style.new(fg: "yellow", bg: "blue", border: true) 18 | 19 | s.append i 20 | 21 | s.focus i 22 | 23 | i.on(::Crysterm::Event::Press) do 24 | STDERR.puts "Pressed; exiting in 2 seconds" 25 | sleep 2.seconds 26 | exit 27 | end 28 | 29 | s.on(::Crysterm::Event::KeyPress) do |e| 30 | if e.char == 'q' || e.key.try(&.==(::Tput::Key::CtrlQ)) 31 | s.destroy 32 | exit 33 | end 34 | end 35 | 36 | s.exec 37 | end 38 | end 39 | 40 | X.new 41 | -------------------------------------------------------------------------------- /misc/question.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | 6 | s = Screen.new 7 | q = Widget::Question.new \ 8 | content: "{bold}HOT{/bold} or {underline}NOT{/underline}?", 9 | # visible: true, 10 | parse_tags: true, 11 | top: "20%", 12 | left: "20%", 13 | width: 30, 14 | height: 8, 15 | style: Style.new( 16 | fg: "yellow", 17 | bg: "magenta", 18 | border: Style.new( 19 | fg: "#ffffff" 20 | ), 21 | shadow: true, 22 | ) 23 | 24 | s.append q 25 | # q.focus 26 | # s.render 27 | 28 | loop do 29 | q.ask { |a, b| STDERR.puts "Answered #{a}/#{b}" } 30 | exit 31 | end 32 | 33 | s.on(Event::KeyPress) do |e| 34 | e.accept 35 | STDERR.puts e.inspect 36 | if e.char == 'q' || e.key.try(&.==(::Tput::Key::CtrlQ)) 37 | exit 38 | end 39 | end 40 | 41 | sleep 42 | end 43 | -------------------------------------------------------------------------------- /test/widget-line.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | include Widgets 6 | 7 | s = Screen.new always_propagate: [Tput::Key::CtrlQ] 8 | 9 | c1 = Line.new left: 10, top: 4, orientation: :horizontal 10 | c2 = Line.new left: 10, size: 5, orientation: :vertical 11 | 12 | c3 = Line.new left: 20, top: 9, orientation: :horizontal, size: "90%" 13 | c4 = Line.new left: 20, size: 10, orientation: :vertical 14 | 15 | c5 = HLine.new left: 30, top: 14, size: "80%" 16 | c6 = VLine.new left: 30, size: 15 17 | 18 | c7 = HLine.new left: 40, top: 19, size: "70%" 19 | c8 = VLine.new left: 40, size: 20 20 | 21 | s.append c1, c2, c3, c4, c5, c6, c7, c8 22 | 23 | s.on(Crysterm::Event::KeyPress) do |e| 24 | e.key.try do |k| 25 | case k 26 | when .ctrl_q? 27 | s.destroy 28 | exit 29 | end 30 | end 31 | end 32 | 33 | s.exec 34 | end 35 | -------------------------------------------------------------------------------- /test/widget-bigtext-clock.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | 6 | s = Screen.new optimization: OptimizationFlag::SmartCSR 7 | 8 | b = Widget::BigText.new \ 9 | content: time, 10 | # parse_tags: true, 11 | resizable: true, 12 | top: "center", 13 | left: "center", 14 | style: Style.new( 15 | # fg: "white", 16 | # bg: "gray", 17 | # char: '\u2592', 18 | fg: "white", 19 | alpha: 0.8, 20 | ) 21 | 22 | s.append b 23 | b.focus 24 | s.render 25 | 26 | s.on(Event::KeyPress) do |e| 27 | e.accept 28 | if e.char == 'q' || e.key == Tput::Key::CtrlQ 29 | s.destroy 30 | exit 31 | end 32 | end 33 | 34 | spawn do 35 | loop do 36 | sleep 1.second 37 | b.content = time 38 | s.render 39 | end 40 | end 41 | 42 | s.exec 43 | 44 | def self.time 45 | Time.utc.to_s "%H:%M:%S" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/widget-list.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::CtrlQ] 9 | 10 | # parent: l, 11 | i = Widget::List.new \ 12 | name: "list", 13 | width: "half", 14 | height: "half", 15 | top: "center", 16 | left: "center", 17 | parse_tags: true, 18 | styles: Styles.new(normal: Style.new(bg: "blue", scrollbar: Style.new(bg: "red"), track: Style.new(char: '▒'), padding: 1), selected: Style.new(fg: "yellow", alpha: true, padding: 1)), 19 | track: true, 20 | scrollbar: true 21 | 22 | i.set_items ["{left}one{/}", "{center}two{/}", "{right}three{/}"] 23 | 24 | s.append i 25 | 26 | s.on(Crysterm::Event::KeyPress) do |e| 27 | if e.char == 'q' || e.key == ::Tput::Key::CtrlQ 28 | s.destroy 29 | exit 30 | end 31 | end 32 | 33 | s.exec 34 | end 35 | end 36 | 37 | X.new 38 | -------------------------------------------------------------------------------- /src/widget/line.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Simple Line widget. Draws a horizontal or vertical 4 | class Line < Box 5 | @resizable = true 6 | 7 | property orientation : Tput::Orientation = :horizontal 8 | 9 | def initialize(@orientation = @orientation, char = nil, size = "100%", **box) 10 | super **box 11 | 12 | size.try { |s| self.line_size = s } 13 | 14 | char ||= (@orientation == Tput::Orientation::Vertical ? '│' : '─') 15 | 16 | style.char = char 17 | end 18 | 19 | def line_size=(size) 20 | case @orientation 21 | when Tput::Orientation::Horizontal 22 | self.width = size 23 | when Tput::Orientation::Vertical 24 | self.height = size 25 | else 26 | # Almost useless failsafe case; just prevents having nothing rendering on screen. 27 | self.width = size 28 | self.height = size 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/widget_visibility.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Shows widget on screen 4 | def show 5 | return if self.style.visible? 6 | self.style.visible = true 7 | emit Crysterm::Event::Show 8 | end 9 | 10 | # Hides widget from screen 11 | def hide 12 | return if !self.style.visible? 13 | clear_last_rendered_position 14 | self.style.visible = false 15 | emit Crysterm::Event::Hide 16 | 17 | screen.try do |s| 18 | # s.rewind_focus if focused? 19 | s.rewind_focus if s.focused == self 20 | end 21 | end 22 | 23 | # Toggles widget visibility 24 | def toggle_visibility 25 | self.style.visible? ? hide : show 26 | end 27 | 28 | # Returns whether widget is visible. Currently does not check if all parents are also visible. 29 | def visible? 30 | self.style.visible? 31 | # This version also checks the complete chain of widget parents: 32 | # visible = true 33 | # self_and_each_ancestor { |a| visible &&= a.style.visible? } 34 | # visible 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/widget-loading.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | include Widgets 6 | 7 | s = Screen.new optimization: OptimizationFlag::SmartCSR 8 | 9 | loading = Loading.new \ 10 | align: AlignFlag::HCenter, 11 | width: 36, 12 | height: 18, 13 | icons: ["Preparing", "Loading", "Processing", "Saving", "Analyzing"], 14 | content: "Please wait...", 15 | style: Style.new(alpha: true, fg: "white", bg: "black", border: Border.new(fg: "white", bg: "black")) 16 | 17 | loading2 = Loading.new \ 18 | align: AlignFlag::Center, 19 | compact: true, 20 | interval: 0.2.seconds, 21 | width: 36, 22 | height: 3, 23 | left: -40, 24 | content: "In progress!...", 25 | style: Style.new(border: Border.new(type: BorderType::Line)) 26 | 27 | s.append loading, loading2 28 | 29 | loading.start 30 | loading2.start 31 | 32 | s.on(Event::KeyPress) do |e| 33 | e.accept 34 | if e.char == 'q' || e.key.try(&.==(::Tput::Key::CtrlQ)) 35 | s.destroy 36 | exit 37 | end 38 | end 39 | 40 | s.exec 41 | end 42 | -------------------------------------------------------------------------------- /examples/colorchart.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | include Crysterm 4 | 5 | def draw(s : Screen) 6 | 8.times do |x| 7 | 8.times do |y| 8 | s.fill_region(Widget.sattr(Style.new, x, y), '0', x, x + 1, (y*2), (y*2) + 1) 9 | s.fill_region(Widget.sattr(Style.new, x + 8, y), '0', x + 8, x + 8 + 1, (y*2), (y*2) + 1) 10 | s.fill_region(Widget.sattr(Style.new, x, y + 8), '0', x, x + 1, (y*2) + 1, (y*2) + 2) 11 | s.fill_region(Widget.sattr(Style.new, x + 8, y + 8), '0', x + 8, x + 8 + 1, (y*2) + 1, (y*2) + 2) 12 | end 13 | end 14 | end 15 | 16 | # `Display` is a physical device (terminal hardware or emulator). 17 | # It can be instantiated manually as shown, or for quick coding it can be 18 | # skipped and it will be created automatically when needed. 19 | s = Screen.new 20 | 21 | draw(s) 22 | 23 | s.on(Event::Resize) do 24 | draw(s) 25 | end 26 | 27 | # When q is pressed, exit the demo. 28 | s.on(Event::KeyPress) do |e| 29 | if e.char == 'q' 30 | exit 31 | end 32 | end 33 | 34 | spawn do 35 | loop do 36 | sleep 1.seconds 37 | s.render 38 | end 39 | end 40 | 41 | s.exec 42 | -------------------------------------------------------------------------------- /test/widget-log.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | logger = Widget::Log.new \ 5 | top: "center", 6 | left: "center", 7 | width: "50%", 8 | height: "50%", 9 | parse_tags: false, 10 | keys: false, 11 | # vi: true, 12 | # mouse: true, 13 | scrollback: 100, 14 | style: Style.new( 15 | border: true, 16 | scrollbar: Style.new( 17 | char: ' ', 18 | track: Style.new( 19 | bg: "yellow" 20 | ) 21 | ) 22 | ) 23 | 24 | Screen.global.append logger 25 | # logger.focus 26 | 27 | logger.screen.on(Event::KeyPress) do |e| 28 | if e.char == 'q' || e.key == Tput::Key::CtrlQ 29 | logger.screen.destroy 30 | exit 31 | end 32 | end 33 | 34 | spawn do 35 | i = 0 36 | loop do 37 | sleep 0.5.seconds 38 | # logger.add "Hello {#0fe1ab-fg}world{/}: {bold}#{Time.utc}{/bold}." 39 | logger.add "Hello world: #{Time.utc}." 40 | if rand < 0.3 41 | logger.add({"foo" => {"bar" => {"baz" => i}}}) 42 | end 43 | i += 1 44 | end 45 | end 46 | 47 | logger.screen.exec 48 | end 49 | -------------------------------------------------------------------------------- /small-tests/screen.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new padding: 10 9 | 10 | # l = Widget::Layout.new width: "100%", height: "100%", border: true, style: Style.new( fg: "black", bg: "white" ) 11 | # s.append l 12 | 13 | # parent: l, 14 | i = Widget::Box.new \ 15 | width: 10, 16 | height: 10, 17 | top: 0, 18 | left: 0, 19 | content: "Test", # "center", left: "center" #, border: true 20 | style: Style.new(fg: "yellow", bg: "red", border: true) 21 | 22 | # parent: l, 23 | i2 = Widget::Box.new \ 24 | width: 10, 25 | height: 10, 26 | top: 0, 27 | left: 20, 28 | content: "Test", # "center", left: "center" #, border: true 29 | style: Style.new(fg: "black", bg: "red", border: true) 30 | 31 | s.append i 32 | s.append i2 33 | 34 | s.on(Crysterm::Event::KeyPress) do |e| 35 | if e.char == 'q' || e.key = ::Tput::Key::CtrlQ 36 | s.destroy 37 | exit 38 | end 39 | end 40 | 41 | s.render 42 | 43 | s.exec 44 | end 45 | end 46 | 47 | X.new 48 | -------------------------------------------------------------------------------- /src/screen_rows.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # Screen rows and cells 4 | 5 | # Individual screen cell 6 | class Cell 7 | include Comparable(self) 8 | 9 | property attr : Int32 = Screen::DEFAULT_ATTR 10 | 11 | property char : Char = Screen::DEFAULT_CHAR 12 | 13 | def initialize(@attr, @char) 14 | end 15 | 16 | def initialize(@char) 17 | end 18 | 19 | def initialize 20 | end 21 | 22 | def <=>(other : Cell) 23 | if (d = @attr <=> other.attr) == 0 24 | @char <=> other.char 25 | else 26 | d 27 | end 28 | end 29 | 30 | def <=>(other : Tuple(Int32, Char)) 31 | if (d = @attr <=> other[0]) == 0 32 | @char <=> other[1] 33 | else 34 | d 35 | end 36 | end 37 | end 38 | 39 | # Individual screen row 40 | class Row < Array(Cell) 41 | property dirty = false 42 | 43 | def initialize 44 | super 45 | end 46 | 47 | def initialize(width, cell : Cell | Tuple(Int32, Char) = {@attr, @char}) 48 | super width 49 | end 50 | end 51 | # end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test-auto/README.md: -------------------------------------------------------------------------------- 1 | # Automatic testing 2 | 3 | Files in this directory perform automatic testing. 4 | 5 | The purpose of these tests is to: 6 | 7 | 1. Be able to spot changes in code that have consequences for rendering or functionality 8 | 2. Test rendering in different terminals 9 | 3. Have a comparison with Blessed 10 | 11 | Some tests have their .js equivalents, which allows comparing the output to Blessed. 12 | 13 | After running both .cr and .js tests, which will each store their outputs in 14 | corresponding language-specific files, you can use `git diff` to identify any differences 15 | compared to previous runs, or `diff` to compare outputs/differences between the two 16 | implementations. 17 | 18 | Example: 19 | 20 | ``` 21 | # Run all Crysterm tests: 22 | ./run-crysterm.sh 23 | 24 | # Set up Blessed and run all Blessed tests: 25 | cd .. 26 | git checkout https://github.com/chjj/blessed 27 | patch -p1 < blessed.patch 28 | cd test-auto 29 | ./run-blessed.sh 30 | 31 | # To compare differences to previous runs: 32 | git diff 33 | 34 | # To compare differences between two implementations, e.g.: 35 | diff -u hello/output/snapshot.blessed hello/output/snapshot.crysterm 36 | ``` 37 | -------------------------------------------------------------------------------- /small-tests/elements.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | # This is a basic example from which widget.png is produced 4 | # (See misc/widget.*) 5 | 6 | class X 7 | include Crysterm 8 | include EventHandler 9 | 10 | def initialize 11 | s = Screen.new 12 | 13 | b = Widget::Box.new \ 14 | width: 160, 15 | height: 40, 16 | top: 0, 17 | left: 0, 18 | style: Style.new(bg: "#ff5600") 19 | 20 | i = Widget::Button.new \ 21 | width: 40, 22 | height: 11, 23 | top: 4, 24 | left: 28, 25 | label: "Frame text ", 26 | content: "Press q or Ctrl+q to exit", 27 | align: ::Tput::AlignFlag::Center, 28 | style: Style.new(fg: "yellow", bg: "blue", alpha: 0.9, border: true, padding: 4, shadow: true) 29 | 30 | s.append b 31 | s.append i 32 | 33 | s.focus i 34 | 35 | i.on(::Crysterm::Event::Press) do 36 | STDERR.puts "Pressed; exiting in 2 seconds" 37 | sleep 2.seconds 38 | exit 39 | end 40 | 41 | s.on(::Crysterm::Event::KeyPress) do |e| 42 | if e.char == 'q' || e.key.try(&.==(::Tput::Key::CtrlQ)) 43 | s.destroy 44 | exit 45 | end 46 | end 47 | 48 | s.exec 49 | end 50 | end 51 | 52 | X.new 53 | -------------------------------------------------------------------------------- /small-tests/checkbox.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | include Widgets 6 | 7 | s = Screen.new always_propagate: [Tput::Key::CtrlQ] 8 | s.cursor.shape = CursorShape::None 9 | s.cursor.artificial = true 10 | s.cursor.style.bg = "#0000ff" 11 | s.cursor.style.fg = "#00ff00" 12 | s.cursor.style.char = 'X' 13 | # s.cursor._hidden = true 14 | 15 | st = Styles.new( 16 | normal: Style.new(bg: "blue"), 17 | focused: Style.new(bg: "red") 18 | ) 19 | 20 | c1 = Checkbox.new content: "Checkbox 1", left: 6, top: 0, styles: st 21 | c2 = Checkbox.new content: "Checkbox 2", left: 6, top: 2, styles: st 22 | c3 = Checkbox.new content: "Checkbox 3", left: 6, top: 4, styles: st 23 | c4 = Checkbox.new content: "Checkbox 4", left: 6, top: 6, styles: st 24 | label = Text.new content: "Cycle between widgets with Tab, Shift+Tab. Space to toggle, ctrl+q to quit.", top: 10 25 | 26 | s.append c1, c2, c3, c4, label 27 | 28 | s.on(Crysterm::Event::KeyPress) do |e| 29 | e.key.try do |k| 30 | case k 31 | when .tab? 32 | s.focus_next 33 | when .shift_tab? 34 | s.focus_previous 35 | when .ctrl_q? 36 | exit 37 | end 38 | s.render 39 | end 40 | end 41 | 42 | s.exec 43 | end 44 | -------------------------------------------------------------------------------- /src/widget/overlayimage.cr: -------------------------------------------------------------------------------- 1 | require "w3m_image_display" 2 | 3 | module Crysterm 4 | class Widget 5 | # Good example of w3mimgdisplay commands: 6 | # https://github.com/hut/ranger/blob/master/ranger/ext/img_display.py 7 | 8 | # Overlay (w3m-img) image element 9 | class OverlayImage < Box 10 | property file : String? 11 | property stretch = false 12 | property center = false 13 | property image : W3MImageDisplay::Image? 14 | 15 | def initialize( 16 | @file = nil, 17 | @stretch = false, 18 | @center = true, 19 | **box 20 | ) 21 | super **box 22 | 23 | @file.try { |f| load f } 24 | 25 | handle ::Crysterm::Event::Rendered 26 | end 27 | 28 | def load(@file) 29 | @image = W3MImageDisplay::Image.new @file 30 | end 31 | 32 | def on_rendered(e) 33 | @image.try do |image| 34 | pos = _get_coords(true).not_nil! 35 | # TODO - get coords of content only, without borders/padding 36 | # style.border.try &.adjust(pos) 37 | image.try &.draw(pos.xi, pos.yi, pos.xl - pos.xi, pos.yl - pos.yi, @stretch, @center).sync.sync_communication 38 | end 39 | end 40 | end 41 | 42 | alias Overlayimage = OverlayImage 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/widget/log.cr: -------------------------------------------------------------------------------- 1 | require "./scrollable_text" 2 | 3 | module Crysterm 4 | class Widget 5 | class Log < ScrollableText 6 | property scroll_percentage = 0 7 | 8 | def initialize(@scroll_on_input = false, @scrollback = Int32::MAX, **scrollable_text) 9 | super **scrollable_text 10 | 11 | on Crysterm::Event::SetContent, ->set_content(Crysterm::Event::SetContent) 12 | end 13 | 14 | def set_content(e) 15 | if !@_user_scrolled || @scroll_on_input 16 | self.scroll_percentage = 100 17 | @_user_scrolled = false 18 | screen.try &.render 19 | end 20 | end 21 | 22 | def add(*args) 23 | text = args.inspect 24 | 25 | emit Crysterm::Event::Log, text 26 | 27 | ret = push_line text 28 | 29 | if @_clines.fake.size > @scrollback 30 | shift_line @scrollback // 3 31 | end 32 | 33 | ret 34 | end 35 | 36 | def scroll(offset, always) 37 | if offset == 0 38 | return super offset, always 39 | end 40 | 41 | @_user_scrolled = true 42 | 43 | ret = super offset, always 44 | 45 | if scroll_percentage == 100 46 | @_user_scrolled = false 47 | end 48 | 49 | ret 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /src/screen_decoration.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # For compatibility with widgets. But, as a side-effect, screens can have padding! 4 | # If you define widget at position (0,0), that will be counted after padding. 5 | # (We leave this at nil for no padding. If we used Padding.new that'd create a 6 | # 1 cell padding by default.) 7 | property padding = Padding.default 8 | 9 | # Amount of space taken by decorations on the left side, to be subtracted from widget's total width 10 | def ileft 11 | @padding.left 12 | end 13 | 14 | # Amount of space taken by decorations on top, to be subtracted from widget's total height 15 | def itop 16 | @padding.top 17 | end 18 | 19 | # Amount of space taken by decorations on the right side, to be subtracted from widget's total width 20 | def iright 21 | @padding.right 22 | end 23 | 24 | # Amount of space taken by decorations on bottom, to be subtracted from widget's total height 25 | def ibottom 26 | @padding.bottom 27 | end 28 | 29 | # Returns current screen width. 30 | def iwidth 31 | p = @padding 32 | p.left + p.right 33 | end 34 | 35 | # Returns current screen height. 36 | def iheight 37 | p = @padding 38 | p.top + p.bottom 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/widget-exit.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Widgets # Just for convenience, to not have to write e.g. `Widget::Box` 5 | 6 | s = Screen.new propagate_keys: false, always_propagate: [Tput::Key::CtrlQ] 7 | 8 | b = Box.new( 9 | screen: s, 10 | top: "center", 11 | left: "center", 12 | width: "70%", 13 | resizable: true, 14 | style: Style.new(border: true), 15 | content: "Press Ctrl+q to quit. It should work even though display's keys are locked." 16 | ) 17 | 18 | s.append b 19 | 20 | s.on(Event::KeyPress) do |e| 21 | if e.key == Tput::Key::CtrlQ 22 | s.destroy 23 | 24 | case ARGV[0]? 25 | when "resume" 26 | # Display.global.input.resume # XXX no resume() on IO::FileDescriptor 27 | puts "Resuming stdin (not implemented)" 28 | when "end" 29 | # This will happen on at_exit; not needed to do here. 30 | # Well, can do both, but then at_exit handler will throw -EBADF 31 | # Display.global.input.cooked! 32 | # Display.global.input.close 33 | puts "Ending stdin (not implemented)" 34 | else 35 | puts "Not resuming nor ending. Can also run test with argument 'resume' or 'end'." 36 | end 37 | 38 | exit 39 | end 40 | end 41 | 42 | s.render 43 | 44 | s.exec 45 | end 46 | -------------------------------------------------------------------------------- /small-tests/radiobutton.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | 6 | s = Screen.new 7 | 8 | se = Widget::RadioSet.new 9 | 10 | st = Styles.new( 11 | normal: Style.new(fg: "yellow", bg: "magenta", border: Border.new(fg: "#ffffff")), 12 | focused: Style.new(fg: "yellow", bg: "magenta", border: Border.new(fg: "#ff0000")), 13 | ) 14 | 15 | b = Widget::RadioButton.new top: 2, left: 2, width: nil, height: nil, 16 | parent: se, 17 | resizable: true, content: "RB1", 18 | styles: st 19 | 20 | b2 = Widget::RadioButton.new top: 2, left: 12, width: nil, height: nil, 21 | parent: se, 22 | resizable: true, content: "RB2", 23 | styles: st 24 | 25 | b3 = Widget::RadioButton.new top: 2, left: 22, width: nil, height: nil, 26 | parent: se, 27 | resizable: true, content: "RB3", 28 | styles: st 29 | 30 | s.append se 31 | s.append b 32 | s.append b2 33 | s.append b3 34 | 35 | b.focus 36 | 37 | s.render 38 | 39 | s.on(Event::KeyPress) do |e| 40 | if e.char == 'q' 41 | exit 42 | elsif e.key == ::Tput::Key::CtrlQ 43 | exit 44 | elsif e.key == ::Tput::Key::Tab 45 | s.focus_next 46 | elsif e.key == ::Tput::Key::ShiftTab 47 | s.focus_previous 48 | end 49 | s.render 50 | end 51 | 52 | s.exec 53 | 54 | sleep 55 | end 56 | -------------------------------------------------------------------------------- /src/widgets.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | # Convenience namespace for widgets 3 | # 4 | # include Widgets 5 | # t = Text.new 6 | module Widgets 7 | # Blessed-like 8 | Box = Widget::Box 9 | Input = Widget::Input 10 | 11 | OverlayImage = Widget::OverlayImage 12 | ProgressBar = Widget::ProgressBar 13 | Loading = Widget::Loading 14 | Layout = Widget::Layout 15 | Question = Widget::Question 16 | Line = Widget::Line 17 | HLine = Widget::HLine 18 | VLine = Widget::VLine 19 | ListTable = Widget::ListTable 20 | List = Widget::List 21 | 22 | Label = Widget::Label 23 | Text = Widget::Text 24 | ScrollableBox = Widget::ScrollableBox 25 | ScrollableText = Widget::ScrollableText 26 | TextBox = Widget::TextBox 27 | TextArea = Widget::TextArea 28 | 29 | BigText = Widget::BigText 30 | 31 | RadioSet = Widget::RadioSet 32 | RadioButton = Widget::RadioButton 33 | Checkbox = Widget::Checkbox 34 | 35 | Button = Widget::Button 36 | Prompt = Widget::Prompt 37 | Message = Widget::Message 38 | Log = Widget::Log 39 | 40 | # Qt-like 41 | Action = Widget::Action 42 | Menu = Widget::Menu 43 | 44 | # Pine-like 45 | PineHeaderBar = Widget::Pine::HeaderBar 46 | PineStatusBar = Widget::Pine::StatusBar 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /src/widget_decoration.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Widget decorations 4 | 5 | # Returns computed content offset from left 6 | def ileft 7 | (style.border.try(&.left) || 0) + style.padding.left 8 | end 9 | 10 | # Returns computed content offset from top 11 | def itop 12 | (style.border.try(&.top) || 0) + style.padding.top 13 | end 14 | 15 | # Returns computed content offset from right 16 | def iright 17 | (style.border.try(&.right) || 0) + style.padding.right 18 | end 19 | 20 | # Returns computed content offset from bottom 21 | def ibottom 22 | (style.border.try(&.bottom) || 0) + style.padding.bottom 23 | end 24 | 25 | # Returns summed amount of content offset from left and right 26 | def iwidth 27 | # return (style.border 28 | # ? ((style.border.left ? 1 : 0) + (style.border.right ? 1 : 0)) : 0) 29 | # + style.padding.left + style.padding.right 30 | (style.border.try { |border| border.left + border.right } || 0) + 31 | (style.padding.try { |p| p.left + p.right }) 32 | end 33 | 34 | # Returns summed amount of content offset from top and bottom 35 | def iheight 36 | # return (style.border 37 | # ? ((style.border.top ? 1 : 0) + (style.border.bottom ? 1 : 0)) : 0) 38 | # + style.padding.top + style.padding.bottom 39 | (style.border.try { |border| border.top + border.bottom } || 0) + 40 | (style.padding.try { |p| p.top + p.bottom }) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /small-tests/focus.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::Tab, ::Tput::Key::ShiftTab, ::Tput::Key::CtrlQ] 9 | 10 | note = Widget::Text.new content: "Use Tab/Shift+Tab to cycle between boxes, Ctrl+q to exit" 11 | 12 | styles = Styles.new( 13 | normal: Style.new(fg: "green", bg: "blue", border: true), 14 | focused: Style.new(fg: "green", bg: "red", border: true), 15 | ) 16 | 17 | i1 = Widget::Checkbox.new \ 18 | name: "w1", 19 | width: 10, 20 | height: 3, 21 | top: 6, 22 | left: 6, 23 | content: "Box1", 24 | styles: styles 25 | 26 | i2 = Widget::Checkbox.new \ 27 | name: "w2", 28 | width: 10, 29 | height: 3, 30 | top: 6, 31 | left: 18, 32 | content: "Box2", 33 | styles: styles 34 | 35 | i3 = Widget::Checkbox.new \ 36 | name: "w3", 37 | width: 10, 38 | height: 3, 39 | top: 6, 40 | left: 30, 41 | content: "Box3", 42 | styles: styles 43 | 44 | s.append i1, i2, i3, note 45 | 46 | s.on(Crysterm::Event::KeyPress) do |e| 47 | if e.key == ::Tput::Key::CtrlQ 48 | s.destroy 49 | exit 50 | elsif e.key == ::Tput::Key::Tab 51 | s.focus_next 52 | elsif e.key == ::Tput::Key::ShiftTab 53 | s.focus_previous 54 | end 55 | s.render 56 | end 57 | 58 | s.exec 59 | end 60 | end 61 | 62 | X.new 63 | -------------------------------------------------------------------------------- /src/widget/radiobutton.cr: -------------------------------------------------------------------------------- 1 | require "./radioset" 2 | 3 | module Crysterm 4 | class Widget 5 | # Radio button element 6 | class RadioButton < Checkbox 7 | include EventHandler 8 | 9 | # TODO option for changing icons 10 | 11 | # Add support for real toggling instead of unchecking 12 | # other elements. So that one can even make a widget 13 | # where only 1 is unchecked, the rest are all checked. 14 | 15 | # getter value = false 16 | 17 | # def initialize(value = false, **element) 18 | def initialize(**checkbox) 19 | super **checkbox 20 | 21 | handle Crysterm::Event::Check 22 | end 23 | 24 | def render 25 | clear_last_rendered_position true 26 | set_content ("(" + (@value ? '*' : ' ') + ") " + @text), true 27 | super false 28 | end 29 | 30 | def on_check(e) 31 | el = self 32 | while el && (el = el.parent) 33 | if el.is_a?(RadioSet) # || el.is_a?(Form) 34 | break 35 | end 36 | end 37 | el = el || parent 38 | 39 | el.try &.each_descendant do |cel| 40 | # TODO 41 | # next if !(cel.is_a? RadioButton) || cel == self 42 | # cel.toggle if cel.is_a?(RadioButton) && cel != self 43 | cel.uncheck if cel.is_a?(RadioButton) && cel != self 44 | end 45 | # TODO 46 | # el.try &.children.each do |cel| 47 | # # next if !(cel.is_a? RadioButton) || cel == self 48 | # cel.uncheck if cel.is_a?(RadioButton) && cel != self 49 | # end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /examples/hello2.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class MyProg 4 | include Crysterm 5 | 6 | s = Screen.global 7 | 8 | # `Box` is one of the available widgets. It is a read-only space for 9 | # displaying text etc. In Qt terms, this is a Label. 10 | b = Widget::Box.new \ 11 | top: 0, 12 | left: 0, 13 | width: "100%", 14 | height: "100%-2", 15 | content: "Content goes here. Press ENTER to start, then type things in.\n" + 16 | "Press ENTER to add line to main box. Ctrl+q to quit.", 17 | parse_tags: true, 18 | style: Style.new(fg: "yellow", bg: "blue", border: true), 19 | parent: s 20 | 21 | # User input box 22 | input = Widget::TextBox.new \ 23 | top: "100%-2", 24 | left: 0, 25 | width: "100%", 26 | height: 1, 27 | style: Style.new(fg: "black", bg: "green"), 28 | parent: s 29 | 30 | input.focus 31 | 32 | # When q is pressed, exit the demo. All input first goes to the `Display`, 33 | # before being passed onto the focused widget, and then up its parent 34 | # tree. So attaching a handler to `Display` is the correct way to handle 35 | # the key press as early as possible. 36 | s.on(Event::KeyPress) do |e| 37 | if e.key == Tput::Key::CtrlQ 38 | exit 39 | end 40 | end 41 | 42 | # Just basic (suboptimal) way to handle enter pressed in the input box. 43 | # But well, livable for nos. 44 | input.on(Event::KeyPress) do |e| 45 | if e.key == Tput::Key::Enter 46 | c = input.content 47 | c = "~" if c == "" 48 | b.set_content b.content + c + "\n" 49 | input.value = "" 50 | s.render 51 | end 52 | end 53 | 54 | s.exec 55 | end 56 | -------------------------------------------------------------------------------- /blessed.patch: -------------------------------------------------------------------------------- 1 | diff --git a/blessed/lib/widgets/element.js b/blessed/lib/widgets/element.js 2 | index 29a783c..084daff 100644 3 | --- a/blessed/lib/widgets/element.js 4 | +++ b/blessed/lib/widgets/element.js 5 | @@ -2563,6 +2563,16 @@ Element.prototype.screenshot = function(xi, xl, yi, yl) { 6 | return this.screen.screenshot(xi, xl, yi, yl); 7 | }; 8 | 9 | +Element.prototype.snapshot = function(includeDecorations = true, dxi = 0, dxl = 0, dyi = 0, dyl = 0) { 10 | + xi = this.lpos.xi + (includeDecorations ? 0 : this.ileft) + dxi; 11 | + xl = this.lpos.xl - (includeDecorations ? 0 : -this.iright) + dxl; 12 | + 13 | + yi = this.lpos.yi + (includeDecorations ? 0 : this.itop) + dyi; 14 | + yl = this.lpos.yl - (includeDecorations ? 0 : -this.ibottom) + dyl; 15 | + 16 | + return this.screen.screenshot(xi, xl, yi, yl); 17 | +}; 18 | + 19 | /** 20 | * Expose 21 | */ 22 | diff --git a/blessed/test/widget-layout.js b/blessed/test/widget-layout.js 23 | index 46e447b..2e58440 100644 24 | --- a/blessed/test/widget-layout.js 25 | +++ b/blessed/test/widget-layout.js 26 | @@ -145,13 +145,14 @@ var box12 = blessed.box({ 27 | }); 28 | 29 | if (process.argv[2] !== 'grid') { 30 | + sizes = [ 0.2, 1, 0.3, 0.6, 0.3, 0.9, 0.2, 0.75, 0.1, 0.99 ] 31 | for (var i = 0; i < 10; i++) { 32 | blessed.box({ 33 | parent: layout, 34 | // width: i % 2 === 0 ? 10 : 20, 35 | // height: i % 2 === 0 ? 5 : 10, 36 | - width: Math.random() > 0.5 ? 10 : 20, 37 | - height: Math.random() > 0.5 ? 5 : 10, 38 | + width: sizes[i] > 0.5 ? 10 : 20, 39 | + height: sizes[i] > 0.5 ? 5 : 10, 40 | border: 'line', 41 | content: (i + 1 + 12) + '' 42 | }); 43 | -------------------------------------------------------------------------------- /spec/crysterm_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | class X 4 | include Crysterm::Helpers 5 | end 6 | 7 | x = X.new 8 | 9 | describe Crysterm do 10 | describe "escape" do 11 | it "wraps/escapes { and }" do 12 | x.escape("my").should eq "my" 13 | x.escape("my {").should eq "my {open}" 14 | x.escape("{ { term }").should eq "{open} {open} term {close}" 15 | end 16 | end 17 | 18 | # describe "generate_tags" do 19 | # it "returns named tuple when invoked without text" do 20 | # x.generate_tags({"fg" => "lightblack"}).should eq({ 21 | # open: "{light-black-fg}", 22 | # close: "{/light-black-fg}", 23 | # }) 24 | # end 25 | 26 | # it "returns text wrapped when invoked with text" do 27 | # x.generate_tags({"fg" => "lightblack"}, " text ").should eq \ 28 | # "{light-black-fg} text {/light-black-fg}" 29 | # end 30 | # end 31 | 32 | describe "strip_tags" do 33 | # Strips text of tags and SGR sequences. 34 | # 35 | # ``` 36 | # .gsub(/\{(\/?)([\w\-,;!#]*)\}/, "").gsub(/\e\[[\d;]*m/, "") 37 | # ``` 38 | it "leaves plain strings as-is" do 39 | x.strip_tags("my").should eq "my" 40 | end 41 | 42 | it "strips {...} tags" do 43 | x.strip_tags("1{tag}text{/tag}2").should eq "1text2" 44 | end 45 | 46 | it "cleans a mix of {...} tags and ESC[...m (SGR) sequences" do 47 | x.clean_tags(" 1\e[1;2m{tag}text \e[0m{/tag}2 ").should eq " 1text 2 " 48 | end 49 | end 50 | 51 | describe "strip_tags" do 52 | it "cleans tags and removes any leading/trailing whitespace" do 53 | x.strip_tags(" 1\e[1;2m{tag}text\e[0m{/tag}2 ").should eq "1text2" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/macros.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Macros 3 | # Defines new_method as an alias of old_method. 4 | # 5 | # This creates a new method new_method that invokes old_method. 6 | # 7 | # Note that due to current language limitations this is only useful 8 | # when neither named arguments nor blocks are involved. 9 | # 10 | # ``` 11 | # class Person 12 | # getter name 13 | # 14 | # def initialize(@name) 15 | # end 16 | # 17 | # alias_method full_name, name 18 | # end 19 | # 20 | # person = Person.new "John" 21 | # person.name # => "John" 22 | # person.full_name # => "John" 23 | # ``` 24 | # 25 | # This macro was present in Crystal until commit 7c3239ee505e07544ec372839efed527801d210a. 26 | macro alias_method(new_method, old_method) 27 | def {{new_method.id}}(*args) 28 | {{old_method.id}}(*args) 29 | end 30 | end 31 | 32 | # Defines new_method as an alias of last (most recently defined) method. 33 | macro alias_previous(*new_methods) 34 | {% for new_method in new_methods %} 35 | alias_method new_method, {{@type.methods.last.name}} 36 | {% end %} 37 | end 38 | 39 | # Registers a handler for the event, named after the event itself. 40 | # This is a convenience function. 41 | # 42 | # E.g.: 43 | # ``` 44 | # handle Event::Attach 45 | # ``` 46 | # 47 | # Will expand into: 48 | # 49 | # ``` 50 | # on(Event::Attach, ->on_attach(Event::Attach) 51 | # ``` 52 | macro handle(event, handler = nil) 53 | on({{event}}, ->on_{{ handler || (event.stringify.split("::")[-1].downcase.id) }}({{event}})) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/crysterm.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require "event_handler" 4 | 5 | require "./ext" 6 | require "./version" 7 | require "./macros" 8 | require "./namespace" 9 | require "./event" 10 | require "./helpers" 11 | require "./colors" 12 | 13 | require "./mixin/*" 14 | 15 | require "./action" 16 | 17 | require "./screen" 18 | 19 | require "./widget" 20 | require "./widget/**" 21 | require "./widgets" 22 | 23 | # Main Crysterm module and namespace. 24 | # 25 | # If your code is in its own namespace, you can shorten `Crysterm` to an 26 | # alias of your choosing, e.g. "C": 27 | # 28 | # ``` 29 | # require "../src/crysterm" 30 | # alias C = Crysterm 31 | # 32 | # s = C::Screen.new 33 | # t = C::Widget::Text.new content: "Hello, World!", style: C::Style.new(bg: "blue", fg: "yellow", border: true), left: "center", top: "center", parent: s 34 | # 35 | # s.append t 36 | # s.on(C::Event::KeyPress) { exit } 37 | # 38 | # s.exec 39 | # ``` 40 | module Crysterm 41 | class GlobalEventsClass 42 | include EventHandler 43 | end 44 | 45 | GlobalEvents = GlobalEventsClass.new 46 | 47 | # TODO Should all of these run a proper exit sequence, instead of just exit ad-hoc? 48 | # (Currently we just call `exit` and count on `at_exit` handlers being invoked, but they 49 | # are unordered) 50 | Signal::TERM.trap do 51 | exit 52 | end 53 | Signal::QUIT.trap do 54 | exit 55 | end 56 | Signal::KILL.trap do 57 | exit 58 | end 59 | Signal::WINCH.trap do 60 | # XXX IIRC, urwid has an additional method of tracking resizes. Check it out and add 61 | # additional support here if necessary. 62 | GlobalEvents.emit Event::Resize 63 | end 64 | 65 | at_exit do 66 | Screen.instances.each &.destroy 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /src/widget_children.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Widget-specific parts of parent/children functionality 4 | 5 | include Mixin::Children 6 | 7 | # Removes node from its parent. 8 | # This is identical to calling `parent.remove(self)`. 9 | def remove_from_parent 10 | @parent.try(&.remove(self)) 11 | end 12 | 13 | # Inserts `element` to list of children at a specified position (at end by default) 14 | def insert(element, i = -1) 15 | if element.screen != screen 16 | element.screen.try &.detach(element) 17 | end 18 | 19 | element.remove_from_parent 20 | 21 | super 22 | screen.try &.attach(element) 23 | 24 | element.parent = self 25 | 26 | element.emit Crysterm::Event::Reparent, self 27 | emit Crysterm::Event::Adopt, element 28 | end 29 | 30 | # Removes `element` from list of children 31 | def remove(element) 32 | return if element.parent != self 33 | 34 | return unless super 35 | element.parent = nil 36 | 37 | # TODO Enable 38 | # if i = screen.clickable.index(element) 39 | # screen.clickable.delete_at i 40 | # end 41 | # if i = screen.keyable.index(element) 42 | # screen.keyable.delete_at i 43 | # end 44 | 45 | element.emit(Crysterm::Event::Reparent, nil) 46 | emit(Crysterm::Event::Remove, element) 47 | # s= screen 48 | # raise Exception.new() unless s 49 | # screen_clickable= s.clickable 50 | # screen_keyable= s.keyable 51 | 52 | screen.try do |s| 53 | s.detach element 54 | 55 | if s.focused == element 56 | s.rewind_focus 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/widget/textbox.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | class TextBox < TextArea 4 | property secret : Bool = false 5 | property censor : Bool = false 6 | getter value : String = "" 7 | 8 | def initialize( 9 | secret = nil, 10 | censor = nil, 11 | parse_tags = false, 12 | input_on_focus = true, 13 | scrollable = false, 14 | **textarea 15 | ) 16 | super **textarea, parse_tags: parse_tags, input_on_focus: input_on_focus, scrollable: scrollable 17 | 18 | secret.try { |v| @secret = v } 19 | censor.try { |v| @censor = v } 20 | end 21 | 22 | def _listener(e : Crysterm::Event::KeyPress) 23 | if e.key == Tput::Key::Enter 24 | e.accept 25 | @_done.try do |done2| 26 | done2.call nil, @value 27 | end 28 | return 29 | end 30 | super 31 | end 32 | 33 | def value=(value = nil) 34 | value ||= @value 35 | 36 | if @_value != value 37 | value = value.gsub /\n/, "" 38 | @value = value 39 | @_value = value 40 | 41 | if @secret 42 | set_content "" 43 | elsif @censor 44 | set_content "*" * value.size 45 | else 46 | val = @value.gsub /\t/, style.tab_char * style.tab_size 47 | visible = (awidth - iwidth - 1) 48 | if visible > val.size 49 | visible = val.size 50 | end 51 | set_content val[-visible..] 52 | end 53 | 54 | _update_cursor 55 | end 56 | end 57 | 58 | def submit 59 | @__listener.try &.call Crysterm::Event::KeyPress.new '\r', Tput::Key::Enter 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /src/widget_interaction.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # module Interaction 4 | 5 | property? interactive = false 6 | 7 | # Is element clickable? 8 | property? clickable = false 9 | 10 | # Can element receive keyboard input? (Managed internally; use `input` for user-side setting) 11 | property? keyable = false 12 | 13 | # Is element draggable? 14 | property? draggable = false 15 | 16 | property? focus_on_click = true 17 | 18 | property? vi : Bool = false 19 | 20 | # Does it accept keyboard input? 21 | property? input = false 22 | 23 | # Should widget react to some pre-defined keys in it? 24 | property? keys : Bool = false 25 | 26 | property? ignore_keys : Bool = false 27 | 28 | # property? clickable = false 29 | 30 | # Puts current widget in focus 31 | def focus 32 | # XXX Prevents getting multiple `Event::Focus`s. Remains to be 33 | # seen whether that's good, or it should always happen, even 34 | # if someone calls `#focus` multiple times in a row. 35 | return if focused? 36 | screen.focus self 37 | end 38 | 39 | # Returns whether widget is currently in focus 40 | @[AlwaysInline] 41 | def focused? 42 | screen.focused == self 43 | end 44 | 45 | def set_hover(hover_text) 46 | end 47 | 48 | def remove_hover 49 | end 50 | 51 | def draggable? 52 | @_draggable 53 | end 54 | 55 | def draggable=(draggable : Bool) 56 | draggable ? enable_drag(draggable) : disable_drag 57 | end 58 | 59 | def enable_drag(x) 60 | @_draggable = true 61 | end 62 | 63 | def disable_drag 64 | @_draggable = false 65 | end 66 | 67 | # :nodoc: 68 | # no-op in this place 69 | def _update_cursor(arg) 70 | end 71 | # end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/screen_resize.cr: -------------------------------------------------------------------------------- 1 | require "event_handler" 2 | 3 | require "./event" 4 | require "./mixin/instances" 5 | require "./mixin/children" 6 | 7 | module Crysterm 8 | class Screen 9 | # File related to display's ability to resize 10 | 11 | # Amount of time to wait before redrawing the screen, after the last successive terminal resize event is received. 12 | # 13 | # The value used in Qt is 0.3 seconds. 14 | # The value commonly used in console apps is 0.2 seconds. 15 | # Yet another choice could be the frame rate, i.e. 1/29 seconds. 16 | # 17 | # This ensures the resizing/redrawing is done only once, once resizing is over. 18 | # To have redraws happen even while resizing is going on, reduce this interval. 19 | property resize_interval : Time::Span = 0.2.seconds 20 | 21 | @_resize_loop_fiber : Fiber? 22 | @_resize_handler : ::Crysterm::Event::Resize::Wrapper? 23 | 24 | # Schedules resize fiber to run at now + `@resize_interval`. Repeated invocations 25 | # (before the interval has elapsed) have a desirable effect of re-starting the timer. 26 | private def schedule_resize 27 | @_resize_loop_fiber.try &.timeout(@resize_interval, Channel::TimeoutAction.new(@resize_interval)) 28 | end 29 | 30 | # Re-reads current size of all `Display`s and triggers redraw of all `Screen`s. 31 | # 32 | # NOTE There is currently no detection for which `Display` the resize has 33 | # happened on, so a resize in any one managed display causes an update and 34 | # redraw of all displays. 35 | def resize 36 | self.tput.reset_screen_size 37 | # # NOTE Tput#screen should have been called `size` or `screen_size` 38 | emit ::Crysterm::Event::Resize.new tput.screen 39 | end 40 | 41 | # :nodoc: 42 | # TODO Will this be affected when we move all GUI actions happening in a single thread? 43 | def resize_loop 44 | loop do 45 | resize 46 | sleep 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /examples/hello.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class MyProg 4 | include Crysterm 5 | 6 | # `Display` is a physical device (terminal hardware or emulator). 7 | # It can be instantiated manually as shown, or for quick coding it can be 8 | # skipped and it will be created automatically when needed. 9 | s = Screen.new title: "Hello, World!" 10 | 11 | # `Box` is one of the available widgets. It is a read-only space for 12 | # displaying text etc. In Qt terms, this is a Label. 13 | b = Widget::Box.new \ 14 | parent: s, 15 | name: "helloworld box", # Symbolic name 16 | top: "center", # Can also be 10, "50%", or "50%-10" 17 | left: "center", # Same as above 18 | width: 20, # ditto 19 | height: 5, # ditto 20 | content: "{center}'Hello {bold}world{/bold}!'\nPress q to quit.{/center}", 21 | parse_tags: true, # Parse {} tags within content (default already is true) 22 | style: Style.new(fg: "yellow", bg: "blue", border: true) 23 | 24 | # Add box to the Screen, because it is a top-level widget without a parent. 25 | # If there is a parent, you would call `Widget#append` on the parent object, 26 | # not on the screen. 27 | 28 | b.focus 29 | 30 | # # Just for show, display the cursor, and later move its position along with 31 | # # the position of the created box. 32 | # s.show_cursor 33 | # s.tput.cursor_shape Tput::CursorShape::Block, blink: true 34 | # s.tput.cursor_color Tput::Color::Goldenrod1 35 | 36 | # When q is pressed, exit the demo. 37 | s.on(Event::KeyPress) do |e| 38 | if e.char == 'q' || e.key == Tput::Key::CtrlQ 39 | s.destroy 40 | exit 41 | end 42 | end 43 | 44 | spawn do 45 | loop do 46 | sleep 2.seconds 47 | b.clear_last_rendered_position 48 | b.top = rand(s.aheight - b.aheight - 1) + 1 49 | b.left = rand(s.awidth - b.awidth) 50 | 51 | # s.tput.cursor_pos b.top, b.left + b.width//2 52 | 53 | s.render 54 | end 55 | end 56 | 57 | s.exec 58 | end 59 | -------------------------------------------------------------------------------- /src/widget_screenshot.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Takes screenshot of a widget. 4 | # 5 | # Does not include decorations, but content only. 6 | # 7 | # It is possible to influence the coordinates that will be 8 | # screenshot with the 4 arguments to the function, but they 9 | # are not intuitive. 10 | def screenshot(xi = nil, xl = nil, yi = nil, yl = nil) 11 | lpos = @lpos 12 | return unless lpos 13 | 14 | xi = lpos.xi + ileft + (xi || 0) 15 | if xl 16 | xl = lpos.xi + ileft + (xl || 0) 17 | else 18 | xl = lpos.xl - iright 19 | end 20 | 21 | yi = lpos.yi + itop + (yi || 0) 22 | if yl 23 | yl = lpos.yi + itop + (yl || 0) 24 | else 25 | yl = lpos.yl - ibottom 26 | end 27 | 28 | screen.screenshot xi, xl, yi, yl 29 | end 30 | 31 | # Takes screenshot of a widget in a more convenient way than `#screenshot`. 32 | # 33 | # To take a screenshot of entire widget, just call `#snapshot`. 34 | # To avoid decorations, use `#snapshot(false)`. 35 | # 36 | # To additionally fine-tune the region, pass 'd' values. For example to enlarge the area of 37 | # screenshot by 1 cell on the left, 2 cells on the right, 3 on top, and 4 on the bottom, call: 38 | # 39 | # ``` 40 | # snapshot(true, -1, 2, -3, 4) 41 | # ``` 42 | # 43 | # This is hopefully better than the equivalent you would have to use with `#screenshot`: 44 | # 45 | # ``` 46 | # screenshot(-ileft - 1, width + iright + 2, -itop - 3, height + ibottom + 4) 47 | # ``` 48 | def snapshot(include_decorations = true, dxi = 0, dxl = 0, dyi = 0, dyl = 0) 49 | lpos = @lpos 50 | return unless lpos 51 | 52 | xi = lpos.xi + (include_decorations ? 0 : ileft) + dxi 53 | xl = lpos.xl + (include_decorations ? 0 : -iright) + dxl 54 | 55 | yi = lpos.yi + (include_decorations ? 0 : itop) + dyi 56 | yl = lpos.yl + (include_decorations ? 0 : -ibottom) + dyl 57 | 58 | screen.screenshot xi, xl, yi, yl 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /examples/chat.js: -------------------------------------------------------------------------------- 1 | // This is a chat example app for Blessed. Put into Blessed's example/ dir and run with: 2 | // node example/chat.js 3 | 4 | var blessed = require('../'); 5 | 6 | // Create a screen object. 7 | var screen = blessed.screen({ dockBorders: true, ignoreDockContrast: true }); 8 | 9 | var style1 = { "fg": "black", "bg": "#729fcf" }; 10 | var style2 = { "fg": "black", "bg": "magenta", transparent: true }; 11 | var style3 = { "fg": "red", "bg": "green", "bar": { fg: "gray", bg: "yellow" } }; 12 | 13 | var sidebar = 40; 14 | 15 | // Create a box perfectly centered horizontally and vertically. 16 | var chat = blessed.textarea({ 17 | top: 0, 18 | left: 0, 19 | width: "100%", 20 | height: "100%-3", 21 | value: "Chat session ...", 22 | parse_tags: false, 23 | border: { "type": "line", "fg": "black", "bg": "#729fcf" }, 24 | style: style1 25 | }); 26 | 27 | var input = blessed.textbox({ 28 | top: "100%-4", 29 | left: 0, 30 | width: "100%-39", // - sidebar 31 | height: 3, 32 | border: { "type": "line", "fg": "black", "bg": "#729fcf" }, 33 | style: style1 34 | }); 35 | 36 | var members = blessed.list({ 37 | top: 0, 38 | left: "100%-40", 39 | width: 40, 40 | height: "100%-3", 41 | border: { "type": "line", "fg": "black", "bg": "#729fcf" }, 42 | scrollbar: true, 43 | transparent: true, 44 | style: style2, 45 | parse_tags: true, 46 | items: [ 'member1', 'member2', 'member3' ], 47 | }); 48 | 49 | var lag = blessed.progressbar({ 50 | top: "100%-4", 51 | left: "100%-40", 52 | width: 40, 53 | height: 3, 54 | border: { "type": "line", "fg": "black", "bg": "#729fcf" }, 55 | content: "", 56 | parseTags: true, 57 | filled: 10, 58 | style: style3 59 | }); 60 | 61 | screen.append(chat); 62 | screen.append(members); 63 | screen.append(input); 64 | screen.append(lag); 65 | 66 | // Quit on Escape, q, or Control-C. 67 | screen.key(['escape', 'q', 'C-c'], function(ch, key) { 68 | return process.exit(0); 69 | }); 70 | 71 | // Focus our element. 72 | chat.focus(); 73 | 74 | // Render the screen. 75 | screen.render(); 76 | -------------------------------------------------------------------------------- /src/widget/pine/header_bar.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | module Pine 4 | class HeaderBar < Widget::Layout 5 | property title 6 | property section 7 | property subsection 8 | property info 9 | 10 | def initialize( 11 | height h = 1, width w = "100%", 12 | title_content = "TITLE", section_content = "SECTION", subsection_content = "SUBSECTION", info_content = "STATUS", 13 | title : Widget? = nil, section : Widget? = nil, subsection : Widget? = nil, info : Widget? = nil, 14 | **layout 15 | ) 16 | super **layout, width: w, height: h 17 | 18 | @style = Style.new inverse: true 19 | 20 | @style_pl2 = Style.new inverse: true, padding: Padding.new(2, 0, 0, 0) 21 | @style_pr2 = Style.new inverse: true, padding: Padding.new(2, 0, 0, 0) 22 | 23 | # @title = title || Widget::Box.new height: h, style: style, width: 16, padding: Padding.new( left: 2, right: 2), content: "TITLE" 24 | # @section = section || Widget::Box.new height: h, style: style, width: "50%-16", content: "SECTION" 25 | # @subsection = subsection || Widget::Box.new height: h, style: style, width: "50%-16", content: "SUBSECTION" 26 | # @info = info || Widget::Box.new height: h, style: style, width: 16, padding: Padding.new( left: 2, right: 2), content: "STATUS", align: Tput::AlignFlag::Right 27 | @title = Widget::Box.new height: h, align: Tput::AlignFlag::VCenter, style: @style_pl2, width: 16, content: title_content 28 | @section = Widget::Box.new height: h, align: Tput::AlignFlag::VCenter, style: @style, width: "50%-16", content: section_content 29 | @subsection = Widget::Box.new height: h, align: Tput::AlignFlag::VCenter, style: @style, width: "50%-16", content: subsection_content 30 | @info = Widget::Box.new height: h, align: Tput::AlignFlag::VCenter | Tput::AlignFlag::Right, style: @style_pr2, width: 16, content: info_content 31 | 32 | append @title, @section, @subsection, @info 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/screen_screenshot.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | def screenshot(xi = 0, xl = awidth, yi = 0, yl = aheight, term = false) 4 | xi = 0 if xi < 0 5 | yi = 0 if yi < 0 6 | 7 | screen_default_attr = @default_attr 8 | 9 | # E O: 10 | # XXX this functionality is currently commented out throughout the function. 11 | # Possibly re-enable, or move to separate function. 12 | # if (term) { 13 | # this.default_attr = term.defAttr; 14 | # } 15 | 16 | main = String::Builder.new 17 | 18 | yi.upto(yl - 1) do |y| 19 | # line = term 20 | # ? term.lines[y] 21 | # : this.lines[y] 22 | line = @lines[y]? 23 | 24 | break if !line 25 | 26 | outbuf = String::Builder.new 27 | attr = @default_attr 28 | 29 | xi.upto(xl - 1) do |x| 30 | break if !line[x]? 31 | 32 | data = line[x].attr 33 | ch = line[x].char 34 | 35 | if data != attr 36 | outbuf << "\e[m" if attr != @default_attr 37 | # if term 38 | # if (((_data >> 9) & 0x1ff) == 257); _data |= 0x1ff << 9 end 39 | # if ((_data & 0x1ff) == 256); _data |= 0x1ff end 40 | # end 41 | outbuf << code2attr(data) if data != @default_attr 42 | end 43 | 44 | # E O: 45 | # if @full_unicode 46 | # if (unicode.charWidth(line[x][1]) === 2) { 47 | # if (x === xl - 1) { 48 | # ch = ' '; 49 | # } else { 50 | # x++; 51 | # } 52 | # } 53 | # } 54 | outbuf << ch 55 | attr = data 56 | end 57 | 58 | if attr != @default_attr 59 | outbuf << "\e[m" 60 | end 61 | 62 | if outbuf.bytesize > 0 63 | main << '\n' if y > yi 64 | main << outbuf.to_s 65 | end 66 | end 67 | 68 | main = main.to_s # .rstrip 69 | main = main.sub(/(?:\s*\e\[40m\s*\e\[m\s*)*$/, "") 70 | main += '\n' 71 | 72 | # if term 73 | # @default_attr = screen_default_attr 74 | # end 75 | 76 | main 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/screen_children.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | def insert(element, i = -1) 4 | # Prevents adding an element twice 5 | super || return 6 | 7 | attach element 8 | 9 | # XXX: 10 | # - Do similar for mouse as well 11 | # - Make sure this is undo-ed if widget is detached 12 | if element.input? || element.keyable? 13 | _listen_keys element 14 | end 15 | 16 | unless self.focused 17 | # element.focus 18 | focus_next 19 | end 20 | end 21 | 22 | # :ditto: 23 | def <<(element) 24 | insert element 25 | end 26 | 27 | def remove(element) 28 | return if element.screen != self 29 | 30 | super 31 | 32 | # TODO Enable 33 | # if i = @display.clickable.index(element) 34 | # @display.clickable.delete_at i 35 | # end 36 | # if i = @display.keyable.index(element) 37 | # @display.keyable.delete_at i 38 | # end 39 | 40 | # s= @display 41 | # raise Exception.new() unless s 42 | # screen_clickable= s.clickable 43 | # screen_keyable= s.keyable 44 | 45 | detach element 46 | 47 | if focused == element 48 | rewind_focus 49 | end 50 | end 51 | 52 | # :ditto: 53 | def >>(element) 54 | remove element 55 | end 56 | 57 | def attach(element) 58 | # Adding an element to Screen consists of setting #screen= (self) on that element 59 | # and all of its children. Attach/Detach events are emitted accordingly. Attaching 60 | # if already attached is a no-op. 61 | element.self_and_each_descendant do |el| 62 | if scr = el.screen? 63 | if scr != self 64 | el.screen = nil 65 | el.emit Crysterm::Event::Detach, scr 66 | end 67 | end 68 | 69 | if !el.screen? 70 | el.screen = self 71 | el.emit Crysterm::Event::Attach, self 72 | end 73 | end 74 | end 75 | 76 | def detach(element) 77 | element.self_and_each_descendant do |el| 78 | if scr = el.screen 79 | el.screen = nil 80 | el.emit Crysterm::Event::Detach, scr 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/widget-prompt.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | s = Screen.new 5 | include Widgets 6 | 7 | prompt = Prompt.new( 8 | screen: s, 9 | style: Style.new(border: true), 10 | resizable: true, 11 | width: "half", 12 | top: "center", 13 | left: "center", 14 | label: " {blue-fg}Prompt{/blue-fg} ", 15 | parse_tags: true, 16 | keys: true, 17 | # vi: true 18 | ) 19 | 20 | question = Question.new( 21 | screen: s, 22 | style: Style.new(border: true), 23 | resizable: true, 24 | width: "half", 25 | top: "center", 26 | left: "center", 27 | label: " {blue-fg}Question{/blue-fg} ", 28 | parse_tags: true, 29 | keys: true, 30 | # vi: true 31 | ) 32 | 33 | msg = Message.new( 34 | screen: s, 35 | style: Style.new(border: true), 36 | resizable: true, 37 | width: "half", 38 | top: "center", 39 | left: "center", 40 | label: " {blue-fg}Message{/blue-fg} ", 41 | parse_tags: true, 42 | keys: true, 43 | visible: false, 44 | # vi: true 45 | ) 46 | 47 | loader = Loading.new( 48 | screen: s, 49 | style: Style.new(border: true), 50 | resizable: true, 51 | width: "half", 52 | top: "center", 53 | left: "center", 54 | label: " {blue-fg}Loader{/blue-fg} ", 55 | parse_tags: true, 56 | keys: true, 57 | visible: false, 58 | # vi: true 59 | ) 60 | 61 | s.append prompt 62 | s.append question 63 | s.append msg 64 | s.append loader 65 | 66 | s.on(Event::KeyPress) do |e| 67 | # STDERR.puts e.inspect 68 | if e.char == 'q' || e.key.try(&.==(::Tput::Key::CtrlQ)) 69 | e.accept 70 | s.destroy 71 | exit 72 | end 73 | end 74 | 75 | prompt.read_input("Question?", "") do |_, _| 76 | STDERR.puts :q1 77 | question.ask("Question?") do |_, _| 78 | STDERR.puts :q2 79 | msg.display("Hello world!", 3.seconds) do # |err| 80 | msg.display("Hello world again!", -1.seconds) do # |err| 81 | loader.load("Loading...") 82 | spawn do 83 | sleep 3.seconds 84 | loader.stop 85 | s.destroy 86 | end 87 | end 88 | end 89 | end 90 | end 91 | 92 | s.exec 93 | end 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Linux CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [master] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Download source 12 | uses: actions/checkout@v2 13 | - name: Install packages 14 | run: sudo apt-get -qy install libunibilium4 libunibilium-dev libreadline-dev libpcre3-dev libevent-dev libgc-dev libyaml-dev libxml2-dev 15 | - name: Install Crystal 16 | uses: oprypin/install-crystal@v1 17 | - name: Cache shards 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.cache/shards 21 | key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} 22 | restore-keys: ${{ runner.os }}-shards- 23 | - name: Install shards 24 | run: shards update --ignore-crystal-version 25 | - name: Run tests 26 | run: crystal spec --order=random --error-on-warnings 27 | - name: Build example 28 | run: crystal build examples/tech-demo.cr 29 | - name: Check formatting 30 | run: crystal tool format --check 31 | release_linux: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Download source 35 | uses: actions/checkout@v2 36 | - name: Install packages 37 | run: sudo apt-get -qy install libunibilium4 libunibilium-dev libreadline-dev libpcre3-dev libevent-dev libgc-dev libyaml-dev libxml2-dev 38 | - name: Install Crystal 39 | uses: oprypin/install-crystal@v1 40 | - name: Cache shards 41 | uses: actions/cache@v2 42 | with: 43 | path: ~/.cache/shards 44 | key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} 45 | restore-keys: ${{ runner.os }}-shards- 46 | - name: Install shards 47 | run: shards update --production --release --static --no-debug --ignore-crystal-version 48 | - name: Build binaries 49 | run: | 50 | crystal build --time --release --static --no-debug -o bin/tech-demo examples/tech-demo.cr 51 | crystal build --time --release --static --no-debug -o bin/hello examples/hello.cr 52 | crystal build --time --release --static --no-debug -o bin/hello2 examples/hello2.cr 53 | crystal build --time --release --static --no-debug -o bin/chat examples/chat.cr 54 | crystal build --time --release --static --no-debug -o bin/shadow small-tests/shadow.cr 55 | -------------------------------------------------------------------------------- /src/mixin/instances.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Mixin 3 | module Instances 4 | macro included 5 | # List of existing instances. 6 | # 7 | # For automatic management of this list, make sure that `#bind` is called at 8 | # creation and `#destroy` at termination. 9 | # 10 | # `#bind` does not have to be called explicitly because it happens during `#initialize`. 11 | # `#destroy` does need to be called. 12 | class_getter instances = [] of self 13 | 14 | # Returns number of created instances 15 | def self.total 16 | @@instances.size 17 | end 18 | 19 | # Creates and/or returns the "global" (first) created instance. 20 | # 21 | # An alternative approach, which is currently not implemented, would be to hold the global 22 | # in a class variable, and return it here. In that way, the choice of the default/global 23 | # object at a particular time would be configurable in runtime. 24 | def self.global(create : Bool = true) 25 | (instances[-1]? || (create ? new : nil)).not_nil! 26 | end 27 | end 28 | 29 | # Accounts for itself in `@@instances` and does other related work. 30 | def bind 31 | if @@instances.includes? self 32 | return 33 | end 34 | 35 | @@instances << self 36 | 37 | # return if @@_bound 38 | # @@_bound = true 39 | 40 | # TODO Enable 41 | # ['SIGTERM', 'SIGINT', 'SIGQUIT'].each do |signal| 42 | # name = '_' + signal.toLowerCase() + 'Handler' 43 | # Signal::<>.trap do 44 | # if listeners(signal).size > 1 45 | # return; 46 | # end 47 | # process.exit(0); 48 | # end 49 | # end 50 | end 51 | 52 | # Destroys self and removes it from the global list of `Screen`s. 53 | # Also remove all global events relevant to the object. 54 | # If no screens remain, the app is essentially reset to its initial state. 55 | def destroy 56 | if @@instances.delete self 57 | # if @@instances.empty? 58 | # @@_bound = false 59 | # end 60 | 61 | emit Crysterm::Event::Destroy 62 | 63 | # super # No longer exists since we're not subclass of Node any more 64 | end 65 | 66 | # display.destroy 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/mixin/style.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Mixin 3 | module Style 4 | # Current state of Widget 5 | 6 | property state = WidgetState::Normal 7 | 8 | # List of styles corresponding to different widget states. 9 | # 10 | # Only one style, `normal` is initialized by default, others default to it if `nil`. 11 | property styles : ::Crysterm::Styles = ::Crysterm::Styles.default 12 | 13 | # User may set specific style for this widget 14 | setter style : ::Crysterm::Style? 15 | 16 | # If specific style is not set, it will depend on current state 17 | def style : ::Crysterm::Style 18 | @style.try { |style| return style } 19 | 20 | case @state 21 | in .normal? 22 | @styles.normal 23 | in .focused? 24 | @styles.focused 25 | in .selected? 26 | @styles.selected 27 | in .hovered? 28 | @styles.hovered 29 | in .blurred? 30 | @styles.blurred 31 | end 32 | end 33 | 34 | # Version with keeping @state and @style in sync: 35 | # getter state = WidgetState::Normal 36 | # # :ditto: 37 | # def state=(state : WidgetState) 38 | # @state = state 39 | # @style = case state 40 | # in .normal? 41 | # @styles.normal 42 | # in .focused? 43 | # @styles.focused 44 | # in .selected? 45 | # @styles.selected 46 | # in .hovered? 47 | # @styles.hovered 48 | # in .blurred? 49 | # @styles.blurred 50 | # end 51 | # end 52 | # Current style being (or to be) applied during rendering. 53 | # This variable is managed by Crysterm and points to currently valid/active style. 54 | # Therefore it is kept in sync (modified together) with `Widget#state`. 55 | # It is a reference to current style, and editing the style through this reference has not been prevented. 56 | # Thus, editing `style` will edit whatever object's `style` is pointing to. 57 | # But note: if widget is e.g. in state `focused` but no special style for focus was defined, 58 | # widget will render use style `normal`. Editing `style` while widget is in that state 59 | # will then actually edit the state for `normal`, not `focused`. 60 | # property style : Style # = Style.new # Placeholder 61 | 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/widget/input.cr: -------------------------------------------------------------------------------- 1 | require "./box" 2 | 3 | module Crysterm 4 | class Widget 5 | # Abstract input element 6 | class Input < Box 7 | @input = true 8 | @resizable = true 9 | 10 | def initialize(*arg, **kwarg) 11 | super 12 | 13 | if @keys && !@ignore_keys 14 | on(Crysterm::Event::KeyPress) do |e| 15 | key = e.key 16 | ch = e.char 17 | 18 | if key == Tput::Key::Up || (@vi && ch == 'k') 19 | scroll(-1) 20 | self.screen.render 21 | next 22 | end 23 | if key == Tput::Key::Down || (@vi && ch == 'j') 24 | scroll(1) 25 | self.screen.render 26 | next 27 | end 28 | 29 | if @vi 30 | # XXX remove all those protections for height being Int 31 | case key 32 | when Tput::Key::CtrlU 33 | height.try do |h| 34 | next unless h.is_a? Int 35 | offs = -h // 2 36 | scroll offs == 0 ? -1 : offs 37 | self.screen.render 38 | end 39 | next 40 | when Tput::Key::CtrlD 41 | height.try do |h| 42 | next unless h.is_a? Int 43 | offs = h // 2 44 | scroll offs == 0 ? 1 : offs 45 | self.screen.render 46 | end 47 | next 48 | when Tput::Key::CtrlB 49 | height.try do |h| 50 | next unless h.is_a? Int 51 | offs = -h 52 | scroll offs == 0 ? -1 : offs 53 | self.screen.render 54 | end 55 | next 56 | when Tput::Key::CtrlF 57 | height.try do |h| 58 | next unless h.is_a? Int 59 | offs = h 60 | scroll offs == 0 ? 1 : offs 61 | self.screen.render 62 | end 63 | next 64 | end 65 | 66 | case ch 67 | when 'g' 68 | scroll_to 0 69 | self.screen.render 70 | next 71 | when 'G' 72 | scroll_to get_scroll_height 73 | self.screen.render 74 | next 75 | end 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /patches/wrap_content.patch: -------------------------------------------------------------------------------- 1 | 2 | # Optimization patch for wrap_content. 3 | # 4 | # If applied, examples/hello.cr has 1 line of difference in the text printed within 5 | # the box. Would be desirable to apply the patch if/when that issue is figured out. 6 | 7 | diff --git a/src/widget_content.cr b/src/widget_content.cr 8 | index 2062713..3518ca8 100644 9 | --- a/src/widget_content.cr 10 | +++ b/src/widget_content.cr 11 | @@ -362,24 +336,19 @@ module Crysterm 12 | end 13 | 14 | # If the string could be too long, check it in more detail and wrap it if needed. 15 | - # NOTE Done with loop+break due to https://github.com/crystal-lang/crystal/issues/1277 16 | - loop_ret = loop do 17 | - break unless line.size > colwidth 18 | - 19 | + while line.size > colwidth 20 | # Measure the real width of the string. 21 | total = 0 22 | i = 0 23 | - # NOTE Done with loop+break due to https://github.com/crystal-lang/crystal/issues/1277 24 | - loop do 25 | - break unless i < line.size 26 | - while (line[i] == '\e') 27 | - while (line[i] && line[i] != 'm') 28 | + while i < line.size 29 | + while line[i] == '\e' 30 | + while line[i] && (line[i] != 'm') 31 | i += 1 32 | end 33 | end 34 | - if (line[i]?.nil?) 35 | - break 36 | - end 37 | + 38 | + break if (line[i]?.nil?) 39 | + 40 | total += 1 41 | if total == colwidth # If we've reached the end of available width of bounding box 42 | i += 1 43 | @@ -421,22 +390,15 @@ module Crysterm 44 | 45 | # Make sure we didn't wrap the line at the very end, otherwise 46 | # we'd get an extra empty line after a newline. 47 | - if line == "" 48 | - break :main 49 | - end 50 | + break if line.empty? 51 | 52 | # If only an escape code got cut off, add it to `part`. 53 | - if (line.matches? /^(?:\e[\[\d;]*m)+$/) # SGR 54 | + if line.matches? /^(?:\e[\[\d;]*m)+$/ # SGR 55 | outbuf[outbuf.size - 1] += line 56 | - break :main 57 | + break 58 | end 59 | end 60 | 61 | - if loop_ret == :main 62 | - no += 1 63 | - next 64 | - end 65 | - 66 | outbuf.push(_align(line, colwidth, align, align_left_too)) 67 | ftor[no].push(outbuf.size - 1) 68 | rtof.push(no) 69 | -------------------------------------------------------------------------------- /small-tests/shadow.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | include Widgets 6 | 7 | s = Screen.new always_propagate: [Tput::Key::CtrlQ], title: "Crysterm Tech Demo" 8 | 9 | bg = Box.new(parent: s, style: Style.new(bg: "#729fcf")) 10 | 11 | boxtp2 = Box.new( 12 | # parent: s, 13 | width: 60, 14 | height: 20, 15 | top: 4, 16 | left: 4, 17 | content: "Hello, World! See translucency and shadow. Use at least\n256 term colors for best results.", 18 | style: Style.new(bg: "#870087", border: Border.new(bg: "#870087"), shadow: Shadow.new) 19 | ) 20 | boxtp1 = Box.new( 21 | # parent: s, 22 | top: 10, 23 | left: 10, 24 | width: 35, 25 | height: 8, 26 | content: "alpha=0.5 (default).\nBorders at top and\nbottom.", 27 | style: Style.new(bg: "#729fcf", alpha: true, border: true, shadow: Shadow.new(0, 1, 0, 2)) 28 | ) 29 | boxtp0 = Box.new( 30 | # parent: s, 31 | top: 20, 32 | left: 49, 33 | width: 20, 34 | height: 8, 35 | content: "alpha=0.2", 36 | style: Style.new(bg: "#729fcf", alpha: true, border: true, shadow: Shadow.new(6, 1, 6, 1, 0.2)) 37 | ) 38 | boxtp3 = Box.new( 39 | # parent: s, 40 | top: 16, 41 | left: 8, 42 | width: 20, 43 | height: 12, 44 | content: "alpha=1", 45 | style: Style.new(fg: "black", bg: "#729fcf", alpha: 1, border: true, shadow: Shadow.new(2, 1, 2, 1)) 46 | ) 47 | boxtpm1 = Box.new( 48 | # parent: s, 49 | top: 7, 50 | left: 30, 51 | width: 20, 52 | height: 8, 53 | content: "See indeed.", 54 | style: Style.new(bg: "#729fcf", alpha: true, border: true, shadow: true) 55 | ) 56 | boxtpm2 = Box.new( 57 | # parent: s, 58 | top: 7, 59 | left: 55, 60 | width: 20, 61 | height: 8, 62 | content: "alpha=0.7", 63 | style: Style.new(bg: "#729fcf", alpha: true, border: true, shadow: Shadow.new(true, true, false, false, 0.7)) 64 | ) 65 | s.append boxtp2 66 | s.append boxtp1 67 | s.append boxtp0 68 | s.append boxtp3 69 | s.append boxtpm1 70 | s.append boxtpm2 71 | 72 | s.on(Event::KeyPress) do |e| 73 | # e.accept 74 | if e.key == ::Tput::Key::CtrlQ || e.char == 'q' 75 | s.destroy 76 | exit 77 | elsif e.key == ::Tput::Key::Up 78 | boxtp0.style.shadow.alpha += 0.1 79 | boxtp0.content = "alpha=#{boxtp0.style.shadow.alpha}" 80 | s.render 81 | elsif e.key == ::Tput::Key::Down 82 | boxtp0.style.shadow.alpha -= 0.1 83 | boxtp0.content = "alpha=#{boxtp0.style.shadow.alpha}" 84 | s.render 85 | end 86 | end 87 | 88 | s.exec 89 | end 90 | -------------------------------------------------------------------------------- /src/widget/loading.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Box element 4 | class Loading < Box 5 | @spinner : Fiber? 6 | 7 | @interval : Time::Span 8 | 9 | property? compact = false 10 | 11 | protected property should_exit = true 12 | 13 | @orig_text = "" 14 | @text : String? 15 | 16 | # XXX Use a better name than 'icons', so that it doesn't 17 | # seem to imply longer text can't be used. 18 | 19 | getter icons : Array(String) 20 | getter icon : Text 21 | 22 | def initialize( 23 | @compact = false, 24 | @interval = 0.2.seconds, 25 | @icons = ["|", "/", "-", "\\"], 26 | @step = 1, 27 | **box 28 | ) 29 | box["content"]?.try do |c| 30 | @orig_text = c 31 | end 32 | 33 | super **box 34 | 35 | @pos = 0 # @step > 0 ? (@step - 1) : @step 36 | 37 | @icon = Text.new \ 38 | align: Tput::AlignFlag::Center, 39 | top: 2, 40 | left: 1, 41 | right: 1, 42 | height: 1, 43 | content: @icons[0] 44 | 45 | append @icon 46 | end 47 | 48 | def start(@text = nil) 49 | # return if @should_exit 50 | @should_exit = false 51 | 52 | # D O: 53 | # Keep on top: 54 | # @parent.try do |p| 55 | # detach 56 | # p.append self 57 | # end 58 | 59 | show 60 | set_content @text || @orig_text 61 | 62 | # XXX We don't want to do this? (Blessed does it) 63 | # @screen.propagate_keys = false 64 | 65 | @spinner = Fiber.new { 66 | loop do 67 | break if @should_exit 68 | @icon.set_content icons[@pos] 69 | @pos = (@pos + @step) % icons.size 70 | screen.render 71 | sleep @interval 72 | end 73 | }.enqueue 74 | end 75 | 76 | alias_previous :load 77 | 78 | def stop 79 | # XXX We don't want to do this? (Blessed does it) 80 | # @screen.propagate_keys = true 81 | hide 82 | @should_exit = true 83 | @text = nil 84 | screen.render 85 | end 86 | 87 | def toggle 88 | @should_exit ? start : stop 89 | end 90 | 91 | def render 92 | clear_last_rendered_position true 93 | if compact? 94 | set_content "#{@icon.content} #{@text || @orig_text}", true 95 | super false 96 | else 97 | set_content @text || @orig_text 98 | super 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/widget-scrollable-boxes.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | include Widgets 6 | 7 | s = Screen.new( 8 | optimization: :smart_csr, 9 | always_propagate: [Tput::Key::CtrlQ], title: "Crysterm Tech Demo" 10 | ) 11 | 12 | box = ScrollableBox.new( 13 | parent: s, 14 | name: "box", 15 | scrollable: true, 16 | left: "center", 17 | top: "center", 18 | width: "80%", 19 | height: "80%", 20 | scrollbar: true, 21 | style: Style.new( 22 | bg: "green", 23 | border: true, 24 | ), 25 | content: "foobar", 26 | keys: true, 27 | vi: true, 28 | always_scroll: true, 29 | # scrollbar: { 30 | # ch: " ", 31 | # inverse: true, 32 | # }, 33 | ) 34 | 35 | text = ScrollableBox.new( 36 | parent: box, 37 | name: "text", 38 | content: "hello1\nhello2\nhello3\nhello4", 39 | style: Style.new( 40 | bg: "red", 41 | padding: 2, 42 | ), 43 | left: 2, 44 | top: 30, 45 | width: "50%", 46 | height: 6, 47 | ) 48 | 49 | text2 = ScrollableBox.new( 50 | parent: box, 51 | name: "text2", 52 | content: "world", 53 | style: Style.new( 54 | bg: "red", 55 | padding: 1, 56 | ), 57 | left: 2, 58 | top: 50, 59 | width: "50%", 60 | height: 3, 61 | ) 62 | 63 | box2 = ScrollableBox.new( 64 | parent: box, 65 | name: "box2", 66 | scrollable: true, 67 | content: "foo-one\nfoo-two\nfoo-three", 68 | left: "center", 69 | top: 20, 70 | width: "80%", 71 | height: 9, 72 | style: Style.new( 73 | bg: "magenta", 74 | # focus: { 75 | # bg: "blue", 76 | # }, 77 | # hover: { 78 | # bg: "red", 79 | # }, 80 | border: true, 81 | padding: 2, 82 | ), 83 | keys: true, 84 | vi: true, 85 | always_scroll: true, 86 | ) 87 | 88 | box3 = ScrollableBox.new( 89 | parent: box2, 90 | name: "box3", 91 | scrollable: true, 92 | left: 3, 93 | top: 3, 94 | content: "foo", 95 | height: 4, 96 | width: 5, 97 | style: Style.new( 98 | bg: "yellow", 99 | # focus: { 100 | # bg: "blue", 101 | # }, 102 | # hover: { 103 | # bg: "red", 104 | # }, 105 | border: true, 106 | ), 107 | keys: true, 108 | vi: true, 109 | always_scroll: true, 110 | ) 111 | 112 | box.focus 113 | 114 | s.on(Event::KeyPress) do |e| 115 | # e.accept 116 | if e.key == ::Tput::Key::CtrlQ || e.char == 'q' 117 | s.destroy 118 | exit 119 | end 120 | end 121 | 122 | s.exec 123 | end 124 | -------------------------------------------------------------------------------- /src/widget/checkbox.cr: -------------------------------------------------------------------------------- 1 | require "./input" 2 | 3 | module Crysterm 4 | class Widget 5 | # Checkbox element 6 | class CheckBox < Input 7 | include EventHandler 8 | 9 | # TODO support for changing icons 10 | 11 | # TODO potentially, turn toggle() into toggle_value() which 12 | # does just that, and toggle_checked() which does what toggle() 13 | # currently does and also calls render. 14 | 15 | # TODO checkboxes don't have keys enabled by default, so to be 16 | # navigable via keys, they need `screen.enable_keys(checkbox_obj)`. 17 | 18 | property? checked : Bool = false 19 | property value : Bool = false 20 | property text : String = "" 21 | 22 | # def initialize(checked : Bool = false, value : Bool? = nil, **input) 23 | def initialize(@checked : Bool = false, **input) 24 | super **input 25 | 26 | # @value = value.nil? ? checked : value 27 | @value = @checked 28 | 29 | input["content"]?.try do |c| 30 | @text = c 31 | end 32 | 33 | handle Crysterm::Event::KeyPress 34 | handle Crysterm::Event::Focus 35 | handle Crysterm::Event::Blur 36 | # XXX potentially wrap in `if mouse`? 37 | handle Crysterm::Event::Click 38 | end 39 | 40 | def render 41 | clear_last_rendered_position true 42 | set_content ("[" + (checked? ? 'x' : ' ') + "] " + @text), true 43 | super false 44 | end 45 | 46 | def check 47 | return if checked? 48 | @checked = true 49 | @value = !@value 50 | emit Crysterm::Event::Check, @value 51 | end 52 | 53 | def uncheck 54 | return unless checked? 55 | @checked = false 56 | @value = !@value 57 | emit Crysterm::Event::UnCheck, @value 58 | end 59 | 60 | def toggle 61 | checked? ? uncheck : check 62 | end 63 | 64 | def on_keypress(e) 65 | # if e.key == Tput::Key::Enter || e.key == Tput::Key::Space 66 | if e.key == Tput::Key::Enter || e.char == ' ' 67 | e.accept 68 | toggle 69 | screen.try &.render 70 | end 71 | end 72 | 73 | def on_click(e) 74 | toggle 75 | screen.try &.render 76 | end 77 | 78 | def on_focus(e) 79 | return unless lpos = @lpos 80 | screen.try do |s| 81 | s.tput.lsave_cursor self.hash 82 | s.tput.cursor_pos lpos.yi + itop, lpos.xi + 1 + ileft 83 | # s.show_cursor # XXX 84 | end 85 | end 86 | 87 | def on_blur(e) 88 | screen.try do |s| 89 | s.tput.lrestore_cursor self.hash, true 90 | end 91 | end 92 | end 93 | 94 | alias Checkbox = CheckBox 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /src/widget_label.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | # Label, if present is kind of object title/text appearing in the first 4 | # line, similar to a label/title in Qt's QFrame. 5 | # Usually this means you will want the widget to have a padding or border, 6 | # so that the label gets rendered over the border/padding instead of 7 | # over the widget content. 8 | 9 | # Widget implementing the label. If label is asked for and no specific 10 | # widget is set, we create a TextBox with chosen content.. But one can 11 | # set this property manually to have a custom/specific label. 12 | property _label : Widget? 13 | 14 | def _label! 15 | @_label.not_nil! 16 | end 17 | 18 | # Holder for event which will trigger on resize, to adjust the label 19 | @ev_label_resize : Crysterm::Event::Resize::Wrapper? 20 | 21 | # Sets or clears label text 22 | def label=(text : String?) 23 | text ? set_label(text) : remove_label 24 | end 25 | 26 | # Sets widget label. Can be positioned "left" (default) or "right" 27 | def set_label(text : String, side = "left") 28 | # If label widget exists, we update it and return 29 | @_label.try do |_label| 30 | _label.set_content(text) 31 | if side != "right" 32 | # TODO Shouldn't -border.left be border.left, to move it further to the right ? 33 | _label.left = 2 + (style.border.try { |border| -border.left } || 0) 34 | _label.right = nil 35 | else 36 | _label.right = 2 + (style.border.try { |border| -border.right } || 0) 37 | _label.left = nil 38 | end 39 | return 40 | end 41 | 42 | # Or if it doesn't exist, we create it 43 | @_label = _label = Widget::Box.new( 44 | parent: self, 45 | content: text, 46 | top: -itop, 47 | # parse_tags: @parse_tags, 48 | style: style.label, 49 | resizable: true, 50 | ) 51 | 52 | if side != "right" 53 | _label.left = 2 - ileft 54 | else 55 | _label.right = 2 - iright 56 | end 57 | 58 | @ev_label_scroll = on Crysterm::Event::Scroll, ->reposition_label(Crysterm::Event::Scroll) 59 | @ev_label_resize = on Crysterm::Event::Resize, ->reposition_label(Crysterm::Event::Resize) 60 | end 61 | 62 | # Repositions label to the right place. Usually called from resize event 63 | def reposition_label(event = nil) 64 | @_label.try do |_label| 65 | _label.top = @child_base - itop 66 | screen.render 67 | end 68 | end 69 | 70 | # Removes widget label 71 | def remove_label 72 | return unless @_label 73 | off ::Crysterm::Event::Scroll, @ev_label_scroll 74 | off ::Crysterm::Event::Resize, @ev_label_resize 75 | @_label.remove_from_parent 76 | @ev_label_scroll = nil 77 | @ev_label_resize = nil 78 | @_label = nil 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /src/widget/prompt.cr: -------------------------------------------------------------------------------- 1 | require "./box" 2 | 3 | module Crysterm 4 | class Widget 5 | class Prompt < Box 6 | property text : String = "" 7 | 8 | # TODO Positioning is bad for buttons. 9 | # Use a layout for buttons. 10 | # Also, make unlimited number of buttons/choices possible. 11 | # XXX Same fixes here and in Question element. 12 | # Actually OK/Cancel buttons need to be imported from Question. 13 | 14 | @textinput = TextBox.new( 15 | top: 3, 16 | height: 1, 17 | left: 2, 18 | right: 2, 19 | ) 20 | 21 | @ok = Button.new( 22 | top: 5, 23 | height: 1, 24 | width: 6, 25 | left: 2, 26 | resizable: true, 27 | content: "Okay", 28 | align: Tput::AlignFlag::Center, 29 | # bg: "black", 30 | # hover_bg: "blue", 31 | focus_on_click: false, 32 | # mouse: true 33 | ) 34 | 35 | @cancel = Button.new( 36 | left: 10, 37 | top: 5, 38 | width: 8, 39 | height: 1, 40 | resizable: true, 41 | content: "Cancel", 42 | align: Tput::AlignFlag::Center, 43 | # bg: "black", 44 | # hover_bg: "blue", 45 | focus_on_click: false, 46 | # mouse: true 47 | ) 48 | 49 | def initialize(**box) 50 | # style.visible = false # XXX Enable correctly 51 | 52 | box["content"]?.try do |c| 53 | @text = c 54 | end 55 | 56 | super **box 57 | 58 | append @textinput 59 | append @ok 60 | append @cancel 61 | end 62 | 63 | def read_input(text = nil, value = "", &callback : Proc(String, String, Nil)) 64 | set_content text || @text 65 | show 66 | 67 | @textinput.value = value 68 | 69 | screen.save_focus 70 | # focus 71 | 72 | # ev_keys = screen.on(Event::KeyPress) do |e| 73 | # next unless (e.key == Tput::Key::Enter || e.key == Tput::Key::Escape) 74 | # done.call nil, e.key == Tput::Key::Enter 75 | # end 76 | 77 | ev_ok = @ok.on ::Crysterm::Event::Press, ->on_press_ok(::Crysterm::Event::Press) 78 | 79 | ev_cancel = @cancel.on ::Crysterm::Event::Press, ->on_press_cancel(::Crysterm::Event::Press) 80 | 81 | @textinput.read_input do |err, data| 82 | hide 83 | screen.restore_focus 84 | @ok.off ::Crysterm::Event::Press, ev_ok 85 | @cancel.off ::Crysterm::Event::Press, ev_cancel 86 | 87 | callback.try do |c| 88 | c.call err, data 89 | end 90 | end 91 | 92 | screen.render 93 | end 94 | 95 | def on_press_ok(e) 96 | @textinput.submit 97 | end 98 | 99 | def on_press_cancel(e) 100 | @textinput.cancel 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /small-tests/tags.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class X 4 | include Crysterm 5 | include EventHandler 6 | 7 | def initialize 8 | s = Screen.new always_propagate: [::Tput::Key::CtrlQ] 9 | 10 | # parent: l, 11 | i = Widget::TextArea.new \ 12 | width: "100%", 13 | height: "100%", 14 | style: Style.new(border: true), 15 | input_on_focus: true, 16 | content: " 17 | {center}center{/center} 18 | {left}left{/left} 19 | {right}right{/right} 20 | {normal}normal{/normal}" 21 | # and {default}default{/default} 22 | # {bold}bold{/bold} 23 | # {underline}underline{/underline}, {underlined}underlined{/underlined}, and {ul}ul{/ul} 24 | # {blink}blink{/blink} 25 | # {inverse}inverse{/inverse} 26 | # {invisible}invisible{/invisible} 27 | # 28 | # {default-bg} default {/default-bg} 29 | # {black-bg} black {/black-bg} 30 | # {blue-bg} blue {/blue-bg} 31 | # {bright black-bg} bright black {/bright black-bg} 32 | # {bright blue-bg} bright blue {/bright blue-bg} 33 | # {bright cyan-bg} bright cyan {/bright cyan-bg} 34 | # {bright gray-bg} bright gray {/bright gray-bg} 35 | # {bright green-bg} bright green {/bright green-bg} 36 | # {bright grey-bg} bright grey {/bright grey-bg} 37 | # {bright magenta-bg} bright magenta {/bright magenta-bg} 38 | # {bright red-bg} bright red {/bright red-bg} 39 | # {bright white-bg} bright white {/bright white-bg} 40 | # {bright yellow-bg} bright yellow {/bright yellow-bg} 41 | # {cyan-bg} cyan {/cyan-bg} 42 | # {gray-bg} gray {/gray-bg} 43 | # {green-bg} green {/green-bg} 44 | # {grey-bg} grey {/grey-bg} 45 | # {light black-bg} light black {/light black-bg} 46 | # {light blue-bg} light blue {/light blue-bg} 47 | # {light cyan-bg} light cyan {/light cyan-bg} 48 | # {light gray-bg} light gray {/light gray-bg} 49 | # {light green-bg} light green {/light green-bg} 50 | # {light grey-bg} light grey {/light grey-bg} 51 | # {light magenta-bg} light magenta {/light magenta-bg} 52 | # {light red-bg} light red {/light red-bg} 53 | # {light white-bg} light white {/light white-bg} 54 | # {light yellow-bg} light yellow {/light yellow-bg} 55 | # {magenta-bg} magenta {/magenta-bg} 56 | # {red-bg} red {/red-bg} 57 | # {white-bg} white {/white-bg} 58 | # {yellow-bg} yellow {/yellow-bg} 59 | # " 60 | 61 | s.append i 62 | 63 | s.on(Crysterm::Event::KeyPress) do |e| 64 | if e.char == 'q' || e.key == ::Tput::Key::CtrlQ 65 | s.destroy 66 | exit 67 | end 68 | end 69 | 70 | s.render 71 | 72 | s.exec 73 | end 74 | end 75 | 76 | X.new 77 | -------------------------------------------------------------------------------- /src/widget/message.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | @resizable = true 4 | @parse_tags = true 5 | 6 | class Message < Box 7 | @ev_keypress : Crysterm::Event::KeyPress::Wrapper? 8 | 9 | def display(text, time : Time::Span? = 10.seconds, &callback : Proc(Nil)) 10 | # D O: 11 | # Keep above: 12 | # parent = @parent 13 | # detach 14 | # parent.append self 15 | 16 | if @scrollable 17 | screen.save_focus 18 | focus 19 | scroll_to 0 20 | end 21 | 22 | show 23 | set_content text 24 | screen.render 25 | 26 | if !time || time.to_f <= 0 27 | spawn do 28 | sleep 10.seconds 29 | 30 | @ev_keypress = screen.on(Crysterm::Event::KeyPress) do |_| 31 | # ##return if e.key.try(&.name) == ::Tput::Key::Mouse # XXX 32 | # #if scrollable? 33 | # # if (e.key == ::Tput::Key::Up) || # || (@vi && e.char == 'k') # XXX 34 | # # (e.key == ::Tput::Key::Down) # || (@vi && e.char == 'j')) # XXX 35 | # # #(@vi && e.key == 'u' && e.key.control?) # XXX 36 | # # #(@vi && e.key == 'd' && e.key.control?) 37 | # # #(@vi && e.key == 'b' && e.key.control?) 38 | # # #(@vi && e.key == 'f' && e.key.control?) 39 | # # #(@vi && e.key == 'g' && !e.key.shift?) 40 | # # #(@vi && e.key == 'g' && e.key.shift?) 41 | # # return 42 | # # end 43 | # #end 44 | # if @ignore_keys.includes? e.key # XXX 45 | # return 46 | # end 47 | @ev_keypress.try do |w| 48 | screen.off ::Crysterm::Event::KeyPress, w 49 | end 50 | end_it do 51 | callback.try &.call 52 | end 53 | end 54 | # XXX May be affected by new @mouse option. 55 | # return unless @mouse 56 | # on_screen_event(::Tput::Key::Mouse) do |e| 57 | # #return if data.action == ::Tput::Mouse::Move 58 | # remove_screen_event(::Tput::Key::Mouse, fn_wrapper) 59 | # end_it callback 60 | # end 61 | end 62 | 63 | return 64 | else 65 | spawn do 66 | sleep time 67 | 68 | hide 69 | screen.render 70 | callback.try &.call 71 | end 72 | end 73 | end 74 | 75 | alias_previous log 76 | 77 | def end_it(&callback : Proc(Nil)) 78 | # return if end_it.done # XXX 79 | # end_it.done = true 80 | if scrollable? 81 | begin 82 | screen.restore_focus 83 | rescue 84 | end 85 | end 86 | hide 87 | screen.render 88 | callback.try &.call 89 | end 90 | 91 | def error(text, time, callback) 92 | display "{red-fg}Error: #{text}{/red-fg}", time, callback 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /src/widget/question.cr: -------------------------------------------------------------------------------- 1 | require "./box" 2 | 3 | module Crysterm 4 | class Widget 5 | # Question element 6 | class Question < Box 7 | property text : String = "" 8 | 9 | # TODO Positioning is bad for buttons. 10 | # Use a layout for buttons. 11 | # Also, make unlimited number of buttons/choices possible. 12 | 13 | @ok = Button.new( 14 | left: 1, 15 | top: 4, 16 | width: 6, 17 | height: 1, 18 | resizable: true, 19 | content: "Okay", 20 | align: Tput::AlignFlag::Center, 21 | # bg: "black", 22 | # hover_bg: "blue", 23 | focus_on_click: false, 24 | # mouse: true 25 | ) 26 | 27 | @cancel = Button.new( 28 | left: 8, 29 | top: 4, 30 | width: 8, 31 | height: 1, 32 | resizable: true, 33 | content: "Cancel", 34 | align: Tput::AlignFlag::Center, 35 | # bg: "black", 36 | # hover_bg: "blue", 37 | focus_on_click: false, 38 | # mouse: true 39 | ) 40 | 41 | def initialize(**box) 42 | # self.style.visible = false # XXX Enable correctly 43 | 44 | box["content"]?.try do |c| 45 | @text = c 46 | end 47 | 48 | super **box 49 | 50 | # Should not be needed when ivar exists and is already set 51 | # @visible = box["visible"]? ? true : box["hidden"]? || false 52 | 53 | append @ok 54 | append @cancel 55 | end 56 | 57 | def ask(text = nil, &block : String?, Bool -> Nil) 58 | # D O: 59 | # Keep above: 60 | # @parent.try do |p| 61 | # detach 62 | # p.append self 63 | # end 64 | 65 | set_content text || @text 66 | show 67 | 68 | done = uninitialized String?, Bool -> Nil 69 | 70 | ev_keys = screen.on(Crysterm::Event::KeyPress) do |e| 71 | # if (e.key == 'mouse') 72 | # return 73 | # end 74 | c = e.char 75 | k = e.key 76 | 77 | if k != Tput::Key::Enter && k != Tput::Key::Escape && c != 'q' && c != 'y' && c != 'n' 78 | next 79 | end 80 | 81 | done.call nil, k == Tput::Key::Enter || e.char == 'y' 82 | end 83 | 84 | ev_ok = @ok.on(Crysterm::Event::Press) do 85 | done.call nil, true 86 | end 87 | 88 | ev_cancel = @cancel.on(Crysterm::Event::Press) do 89 | done.call nil, false 90 | end 91 | 92 | screen.save_focus 93 | focus 94 | 95 | done = ->(err : String?, data : Bool) do 96 | hide 97 | screen.restore_focus 98 | screen.off Crysterm::Event::KeyPress, ev_keys 99 | @ok.off Crysterm::Event::Press, ev_ok 100 | @cancel.off Crysterm::Event::Press, ev_cancel 101 | block.call err, data 102 | screen.render 103 | end 104 | 105 | screen.render 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /examples/chat.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | class MyProg 4 | include Crysterm 5 | 6 | s = Screen.new show_fps: nil, dock_contrast: DockContrast::Blend, dock_borders: true 7 | 8 | style1 = Style.new fg: "black", bg: "#729fcf", border: Border.new(fg: "black", bg: "#729fcf"), scrollbar: Style.new(bg: "#000000"), track: Style.new(bg: "red") 9 | style2 = Style.new fg: "black", bg: "magenta", border: Border.new(fg: "black", bg: "#729fcf"), alpha: 0.5, padding: 1 10 | # style2 = Style.new fg: "white", bg: "#870087", border: Border.new(fg: "black", bg: "#870087", alpha: true), alpha: true 11 | style3 = Style.new fg: "black", "bg": "#729fcf", border: Border.new(fg: "magenta", bg: "#729fcf"), bar: Style.new(fg: "#d75f00") 12 | 13 | chat = Widget::TextArea.new \ 14 | top: 0, 15 | left: 0, 16 | width: "100%-19", 17 | height: "100%-2", 18 | content: "Chat session ...", 19 | parse_tags: false, 20 | style: style1, 21 | scrollbar: true 22 | 23 | input = Widget::TextBox.new \ 24 | top: "100%-3", 25 | left: 0, 26 | width: "100%-19", 27 | height: 3, 28 | style: style1 29 | input.on(Crysterm::Event::Submit) do |e| 30 | chat.set_content "#{chat.content}\n#{e.value}" 31 | input.value = "" 32 | s.render 33 | input.focus 34 | end 35 | 36 | members = Widget::List.new \ 37 | top: 0, 38 | left: "100%-20", 39 | width: 20, 40 | height: "100%-2", 41 | # scrollbar: true, 42 | style: style2 43 | 44 | lag = Widget::ProgressBar.new \ 45 | top: "100%-3", 46 | left: "100%-20", 47 | width: 20, 48 | height: 3, 49 | content: "{center}Lag Indicator{/center}", 50 | parse_tags: true, 51 | filled: 10, 52 | style: style3 53 | 54 | s.append chat 55 | s.append members 56 | s.append lag 57 | s.append input 58 | 59 | input.focus 60 | 61 | # When q is pressed, exit the demo. All input first goes to the `Display`, 62 | # before being passed onto the focused widget, and then up its parent 63 | # tree. So attaching a handler to `Display` is the correct way to handle 64 | # the key press as early as possible. 65 | s.on(Event::KeyPress) do |e| 66 | case e.key 67 | when Tput::Key::CtrlQ 68 | exit 69 | when Tput::Key::Tab 70 | s.focus_next 71 | when Tput::Key::ShiftTab 72 | s.focus_previous 73 | end 74 | end 75 | 76 | spawn do 77 | id = 1 78 | loop do 79 | r = rand 80 | if r < 0.5 81 | # TODO Causes a bug at the moment 82 | # members.append_item "Member #{id}" 83 | chat.set_content "#{chat.content}\n* Member #{id} has joined the conversation." 84 | id += 1 85 | else 86 | delid = rand(id) + 1 87 | members.items[delid]?.try do |item| 88 | members.remove_item(item) && \ 89 | chat.set_content "#{chat.content}\n* #{item.content} has left." 90 | end 91 | end 92 | chat.scroll_to chat.get_content.lines.size 93 | sleep (rand 2).seconds 94 | lag.filled = rand 100 95 | s.render 96 | end 97 | end 98 | 99 | s.exec 100 | end 101 | -------------------------------------------------------------------------------- /src/action.cr: -------------------------------------------------------------------------------- 1 | require "event_handler" 2 | 3 | module Crysterm 4 | # Many common commands can be invoked via different interfaces (menus, toolbar buttons, keyboard shortcuts, etc.). 5 | # Because they are expected to run in the same way, regardless of the user interface used, it is useful to represent them with `Action`s. 6 | # 7 | # Actions can be added to menus and toolbars, and will automatically be kept in sync because they are the same object. 8 | # For example, if the user presses a "Bold" toolbar button in a text editor, the "Bold" menu item will automatically appear enabled where ever it is added. 9 | # 10 | # It is recommended to create `Action`s as children of the window they are used in. 11 | # 12 | # Actions are added to `Widget`s using `#addAction` or `<<(Action)`. Note that an action must be added to a widget before it can be used. 13 | # 14 | # NOTE Actions are inspired by `QAction` (https://doc.qt.io/qt-6/qaction.html) 15 | class Action 16 | include EventHandler 17 | 18 | alias OneOfEvents = Crysterm::Event::Triggered.class | Crysterm::Event::Hovered.class 19 | 20 | # Unused for now, reenable later 21 | # Icon of action 22 | # property icon : Icon? 23 | 24 | # Text / label of action 25 | property text : String = "" 26 | 27 | # Action enabled? 28 | property enabled = true 29 | 30 | # Keyboard shortcut 31 | # TODO Needs to become proper `KeySequence?` later, so that it can trigger on a sequence 32 | # of key presses (E.g. Ctrl+a, d) 33 | alias KeySequence = Tput::Key 34 | property shortcut : KeySequence? 35 | 36 | # Tip to show in status bar, if/when applicable 37 | property status_tip : String? 38 | 39 | # Tip to show in a popup on hover over the action, if/when applicable 40 | setter tool_tip : String? 41 | 42 | # Tip to show in a popup when broader help text / description is requested 43 | property whats_this : String? 44 | 45 | # This property holds whether the action can be seen (e.g. in menus and toolbars) or is hidden. 46 | property? visible = true 47 | 48 | def initialize( 49 | @parent : EventHandler? = nil 50 | # NOTE Passing a block directly to the initializer would be convenient, but because 51 | # it also requires specifying which event to trigger on (thus adding 2 new params), 52 | # it gets unwieldy quickly. So let's stay with the basic interface for now. 53 | # Add the action to execute after creation, simply with: obj.on(Triggered) { block } 54 | # event : OneOfEvents = Crysterm::Event::Triggered, 55 | # &block : ::Proc(Crysterm::Event::Triggered, ::Nil) 56 | ) 57 | # on event, block 58 | end 59 | 60 | def initialize( 61 | @text, 62 | @parent : EventHandler? = nil 63 | ) 64 | end 65 | 66 | # XXX Blocks for initializers are currently disabled. But when we get to enabling them, 67 | # use the same approach that kdebindings' qtruby bindings for Qt4 took to make them 68 | # work. 69 | # def initialize( 70 | # @text, 71 | # @parent : Crysterm::Object? = nil 72 | # event : OneOfEvents = Crysterm::Event::Triggered, 73 | # &block : ::Proc(Event::Triggered, ::Nil) 74 | # ) 75 | # on event, block 76 | # end 77 | 78 | # Alternatively, for overloads with and without a block: 79 | # def foo(&block : Proc(Nil)); foo(block); end; def foo(proc : Proc(Nil)? = nil); proc.try &.call; end; foo; foo { "hello" } 80 | 81 | # def activate(event : ActionEvent = ActionEvent::Event::Triggered) 82 | 83 | # Activates the action 84 | def activate(event : OneOfEvents = Crysterm::Event::Triggered) 85 | emit event 86 | end 87 | 88 | # NOTE Disabled for now so that always #activate(Event) is used 89 | # # Activates the action 90 | # def trigger 91 | # activate Crysterm::Event::Triggered 92 | # end 93 | 94 | # # Activates the action 95 | # def hover 96 | # activate Crysterm::Event::Hovered 97 | # end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/widget-layout.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | # For reproducibility, the section near the end generating 10 widgets has been 4 | # changed to always use fixed sizes instead of Random. The patch for Blessed's 5 | # test file to get the same behavior is in file widget-layout.cr.blessed-patch. 6 | 7 | module Crysterm 8 | s = Screen.new optimization: OptimizationFlag::SmartCSR, dock_borders: false 9 | 10 | l = layout = Widget::Layout.new( 11 | top: "center", 12 | left: "center", 13 | width: "50%", 14 | height: "50%", 15 | layout: ARGV[0]? == "grid" ? LayoutType::Grid : LayoutType::Inline, 16 | overflow: Overflow::Ignore, # Setting not existing in Blessed. Controls what to do when widget is overflowing available space. Value of 'ignore' ignores the issue and renders such widgets overflown. 17 | style: Style.new( 18 | bg: "red", 19 | border: Border.new( 20 | fg: "blue" 21 | ) 22 | ) 23 | ) 24 | 25 | s.append l 26 | 27 | box1 = Widget::Box.new( 28 | parent: layout, 29 | top: "center", 30 | left: "center", 31 | width: 20, 32 | height: 10, 33 | style: Style.new(border: BorderType::Line), 34 | content: "1" 35 | ) 36 | 37 | box2 = Widget::Box.new( 38 | parent: layout, 39 | top: 0, 40 | left: 0, 41 | width: 10, 42 | height: 5, 43 | style: Style.new(border: BorderType::Line), 44 | content: "2" 45 | ) 46 | 47 | box3 = Widget::Box.new( 48 | parent: layout, 49 | top: 0, 50 | left: 0, 51 | width: 10, 52 | height: 5, 53 | style: Style.new(border: BorderType::Line), 54 | content: "3" 55 | ) 56 | 57 | box4 = Widget::Box.new( 58 | parent: layout, 59 | top: 0, 60 | left: 0, 61 | width: 10, 62 | height: 5, 63 | style: Style.new(border: BorderType::Line), 64 | content: "4" 65 | ) 66 | 67 | box5 = Widget::Box.new( 68 | parent: layout, 69 | top: 0, 70 | left: 0, 71 | width: 10, 72 | height: 5, 73 | style: Style.new(border: BorderType::Line), 74 | content: "5" 75 | ) 76 | 77 | box6 = Widget::Box.new( 78 | parent: layout, 79 | top: 0, 80 | left: 0, 81 | width: 10, 82 | height: 5, 83 | style: Style.new(border: BorderType::Line), 84 | content: "6" 85 | ) 86 | 87 | box7 = Widget::Box.new( 88 | parent: layout, 89 | top: 0, 90 | left: 0, 91 | width: 10, 92 | height: 5, 93 | style: Style.new(border: BorderType::Line), 94 | content: "7" 95 | ) 96 | 97 | box8 = Widget::Box.new( 98 | parent: layout, 99 | top: "center", 100 | left: "center", 101 | width: 20, 102 | height: 10, 103 | style: Style.new(border: BorderType::Line), 104 | content: "8" 105 | ) 106 | 107 | box9 = Widget::Box.new( 108 | parent: layout, 109 | top: 0, 110 | left: 0, 111 | width: 10, 112 | height: 5, 113 | style: Style.new(border: BorderType::Line), 114 | content: "9" 115 | ) 116 | 117 | box10 = Widget::Box.new( 118 | parent: layout, 119 | top: "center", 120 | left: "center", 121 | width: 20, 122 | height: 10, 123 | style: Style.new(border: BorderType::Line), 124 | content: "10" 125 | ) 126 | 127 | box11 = Widget::Box.new( 128 | parent: layout, 129 | top: 0, 130 | left: 0, 131 | width: 10, 132 | height: 5, 133 | style: Style.new(border: BorderType::Line), 134 | content: "11" 135 | ) 136 | 137 | box12 = Widget::Box.new( 138 | parent: layout, 139 | top: "center", 140 | left: "center", 141 | width: 20, 142 | height: 10, 143 | style: Style.new(border: BorderType::Line), 144 | content: "12" 145 | ) 146 | 147 | if ARGV[0]? != "grid" 148 | sizes = [0.2, 1, 0.3, 0.6, 0.3, 0.9, 0.2, 0.75, 0.1, 0.99] 149 | 10.times do |i| 150 | Widget::Box.new( 151 | parent: layout, 152 | width: sizes[i] > 0.5 ? 10 : 20, 153 | height: sizes[i] > 0.5 ? 5 : 10, 154 | style: Style.new(border: BorderType::Line), 155 | content: (i + 1 + 12).to_s 156 | ) 157 | end 158 | end 159 | 160 | s.on(Event::KeyPress) do |e| 161 | # STDERR.puts e.inspect 162 | if e.char == 'q' 163 | # e.accept 164 | s.destroy 165 | exit 166 | end 167 | end 168 | 169 | s.render 170 | 171 | s.exec # We use exec to run the main loop. Similar to Qt. 172 | end 173 | -------------------------------------------------------------------------------- /src/mixin/children.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | module Mixin 3 | module Children 4 | # Widget's children `Widget`s. 5 | getter children = [] of Widget 6 | 7 | # Adds `element` to list of children. Convenience method identical to `append` 8 | def <<(widget : Widget) 9 | append widget 10 | end 11 | 12 | # Removes `element` from list of children. Convenience method identical to `remove` 13 | def >>(widget : Widget) 14 | remove widget 15 | end 16 | 17 | # Appends `element` to list of children 18 | def append(element) 19 | insert element 20 | end 21 | 22 | # Appends `element`s to list of children in the order given (first listed is first added) 23 | def append(*elements) 24 | elements.each do |el| 25 | insert el 26 | end 27 | end 28 | 29 | # Prepends node to the list of children 30 | def prepend(element) 31 | insert element, 0 32 | end 33 | 34 | # Adds node to the list of children before the specified `other` element 35 | def insert_before(element, other) 36 | if i = @children.index other 37 | insert element, i 38 | end 39 | end 40 | 41 | # Adds node to the list of children after the specified `other` element 42 | def insert_after(element, other) 43 | if i = @children.index other 44 | insert element, i + 1 45 | end 46 | end 47 | 48 | # Inserts `element` into list of children widgets 49 | def insert(element, i = -1) 50 | return if @children.includes? element 51 | @children.insert i, element 52 | end 53 | 54 | # Removes `element` from list of children widgets 55 | def remove(element) 56 | return unless i = @children.index(element) 57 | element.clear_last_rendered_position 58 | @children.delete_at i 59 | end 60 | 61 | # Returns true if `obj` is found in the list of parents, recursively 62 | def has_ancestor?(obj) 63 | el = self 64 | while el = el.parent 65 | return true if el.same? obj 66 | end 67 | false 68 | end 69 | 70 | # Returns true if `obj` is found in the list of children, recursively 71 | def has_descendant?(obj) 72 | @children.each do |el| 73 | return true if el.same? obj 74 | return true if el.descendant? obj 75 | end 76 | false 77 | end 78 | 79 | # Runs a particular block for self and all descendants, recursively 80 | def self_and_each_descendant(&block : Proc(Widget, Nil)) : Nil 81 | block.call self 82 | each_descendant &block 83 | end 84 | 85 | # Runs a particular block for all descendants, recursively 86 | def each_descendant(&block : Proc(Widget, Nil)) : Nil 87 | f = uninitialized Widget -> Nil 88 | f = ->(el : Widget) { 89 | block.call el 90 | el.children.each do |c| 91 | f.call c 92 | end 93 | } 94 | 95 | @children.each do |el| 96 | f.call el 97 | end 98 | end 99 | 100 | # Runs a particular block for self and all ancestors, recursively 101 | def self_and_each_ancestor(&block : Proc(Widget, Nil)) : Nil 102 | block.call self 103 | each_ancestor &block 104 | end 105 | 106 | # Runs a particular block for all ancestors, recursively 107 | def each_ancestor(&block : Proc(Widget, Nil)) : Nil 108 | f = uninitialized Widget -> Nil 109 | f = ->(el : Widget) { 110 | block.call el 111 | el.parent.try { |el2| f.call el2 } 112 | } 113 | 114 | @parent.try { |el| f.call el } 115 | end 116 | 117 | # Returns a flat list of all children widgets, recursively 118 | def collect_descendants(el : Widget) : Array(Widget) 119 | children = [] of Widget 120 | each_descendant { |e| children << e } 121 | children 122 | end 123 | 124 | # Returns a flat list of all parent widgets, recursively 125 | def collect_ancestors(el : Widget) : Array(Widget) 126 | parents = [] of Widget 127 | each_ancestor { |e| parents << e } 128 | parents 129 | end 130 | 131 | # Emits `ev` on all children nodes, recursively. 132 | def emit_descendants(ev : EventHandler::Event | EventHandler::Event.class) : Nil 133 | each_descendant(&.emit(ev)) 134 | end 135 | 136 | # Emits `ev` on all parent nodes. 137 | def emit_ancestors(ev : EventHandler::Event | EventHandler::Event.class) : Nil 138 | each_ancestor(&.emit(ev)) 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /src/fonts/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Dimitar Toshkov Zhekov, 2 | with Reserved Font Name "Terminus Font". 3 | 4 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 5 | This license is copied below, and is also available with a FAQ at: 6 | http://scripts.sil.org/OFL 7 | 8 | 9 | ----------------------------------------------------------- 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 11 | ----------------------------------------------------------- 12 | 13 | PREAMBLE 14 | The goals of the Open Font License (OFL) are to stimulate worldwide 15 | development of collaborative font projects, to support the font creation 16 | efforts of academic and linguistic communities, and to provide a free and 17 | open framework in which fonts may be shared and improved in partnership 18 | with others. 19 | 20 | The OFL allows the licensed fonts to be used, studied, modified and 21 | redistributed freely as long as they are not sold by themselves. The 22 | fonts, including any derivative works, can be bundled, embedded, 23 | redistributed and/or sold with any software provided that any reserved 24 | names are not used by derivative works. The fonts and derivatives, 25 | however, cannot be released under any other type of license. The 26 | requirement for fonts to remain under this license does not apply 27 | to any document created using the fonts or their derivatives. 28 | 29 | DEFINITIONS 30 | "Font Software" refers to the set of files released by the Copyright 31 | Holder(s) under this license and clearly marked as such. This may 32 | include source files, build scripts and documentation. 33 | 34 | "Reserved Font Name" refers to any names specified as such after the 35 | copyright statement(s). 36 | 37 | "Original Version" refers to the collection of Font Software components as 38 | distributed by the Copyright Holder(s). 39 | 40 | "Modified Version" refers to any derivative made by adding to, deleting, 41 | or substituting -- in part or in whole -- any of the components of the 42 | Original Version, by changing formats or by porting the Font Software to a 43 | new environment. 44 | 45 | "Author" refers to any designer, engineer, programmer, technical 46 | writer or other person who contributed to the Font Software. 47 | 48 | PERMISSION & CONDITIONS 49 | Permission is hereby granted, free of charge, to any person obtaining 50 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 51 | redistribute, and sell modified and unmodified copies of the Font 52 | Software, subject to the following conditions: 53 | 54 | 1) Neither the Font Software nor any of its individual components, 55 | in Original or Modified Versions, may be sold by itself. 56 | 57 | 2) Original or Modified Versions of the Font Software may be bundled, 58 | redistributed and/or sold with any software, provided that each copy 59 | contains the above copyright notice and this license. These can be 60 | included either as stand-alone text files, human-readable headers or 61 | in the appropriate machine-readable metadata fields within text or 62 | binary files as long as those fields can be easily viewed by the user. 63 | 64 | 3) No Modified Version of the Font Software may use the Reserved Font 65 | Name(s) unless explicit written permission is granted by the corresponding 66 | Copyright Holder. This restriction only applies to the primary font name as 67 | presented to the users. 68 | 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 70 | Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except to acknowledge the contribution(s) of the 72 | Copyright Holder(s) and the Author(s) or with their explicit written 73 | permission. 74 | 75 | 5) The Font Software, modified or unmodified, in part or in whole, 76 | must be distributed entirely under this license, and must not be 77 | distributed under any other license. The requirement for fonts to 78 | remain under this license does not apply to any document created 79 | using the Font Software. 80 | 81 | TERMINATION 82 | This license becomes null and void if any of the above conditions are 83 | not met. 84 | 85 | DISCLAIMER 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 94 | OTHER DEALINGS IN THE FONT SOFTWARE. 95 | -------------------------------------------------------------------------------- /src/screen_angles.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # Collection of helper chars for drawing borders and their angles 4 | 5 | # Left, top, right, and bottom angles 6 | L_ANGLES = {'┌', '└', '┼', '├', '┴', '┬', '─'} 7 | U_ANGLES = {'┐', '┌', '┼', '├', '┤', '┬', '│'} 8 | R_ANGLES = {'┘', '┐', '┼', '┤', '┴', '┬', '─'} 9 | D_ANGLES = {'┘', '└', '┼', '├', '┤', '┴', '│'} 10 | 11 | # All angles, uniq list 12 | ANGLES = {'┘', '┐', '┌', '└', '┼', '├', '┤', '┴', '┬', '│', '─'} 13 | 14 | # Every ACS angle character can be 15 | # represented by 4 bits ordered like this: 16 | # [langle][uangle][rangle][dangle] 17 | ANGLE_TABLE = { 18 | 0 => ' ', # ? '0000' 19 | 1 => '│', # ? '0001' 20 | 2 => '─', # ?? '0010' 21 | 3 => '┌', # '0011' 22 | 4 => '│', # ? '0100' 23 | 5 => '│', # '0101' 24 | 6 => '└', # '0110' 25 | 7 => '├', # '0111' 26 | 8 => '─', # ?? '1000' 27 | 9 => '┐', # '1001' 28 | 10 => '─', # ?? '1010' 29 | 11 => '┬', # '1011' 30 | 12 => '┘', # '1100' 31 | 13 => '┤', # '1101' 32 | 14 => '┴', # '1110' 33 | 15 => '┼', # '1111' 34 | } 35 | 36 | BITWISE_L_ANGLE = 1 << 3 37 | BITWISE_U_ANGLE = 1 << 2 38 | BITWISE_R_ANGLE = 1 << 1 39 | BITWISE_D_ANGLE = 1 << 0 40 | 41 | # Returns appropriate angle char for point (y,x) in `lines` 42 | # 43 | # To operate, needs `lines` (the 2d array of cells), and (y,x) point 44 | # you're asking for. 45 | def _get_angle(lines, x, y) 46 | angle = 0 47 | attr = lines[y][x].attr 48 | ch = lines[y][x].char 49 | 50 | if lines[y][x - 1]? && L_ANGLES.includes? lines[y][x - 1].char 51 | if lines[y][x - 1].attr != attr 52 | case @dock_contrast 53 | when DockContrast::DontDock 54 | return ch 55 | when DockContrast::Blend 56 | lines[y][x].attr = Colors.blend lines[y][x - 1].attr, attr 57 | # when DockContrast::Ignore 58 | # Note: ::Ignore needs no custom handler/code; it works as-is. 59 | end 60 | end 61 | angle |= BITWISE_L_ANGLE 62 | end 63 | 64 | if lines[y - 1]? && U_ANGLES.includes? lines[y - 1][x].char 65 | if lines[y - 1][x].attr != attr 66 | case @dock_contrast 67 | when DockContrast::DontDock 68 | return ch 69 | when DockContrast::Blend 70 | lines[y][x].attr = Colors.blend lines[y - 1][x].attr, attr 71 | # when DockContrast::Ignore 72 | # Note: ::Ignore needs no custom handler/code; it works as-is. 73 | end 74 | end 75 | angle |= BITWISE_U_ANGLE 76 | end 77 | 78 | if lines[y][x + 1]? && R_ANGLES.includes? lines[y][x + 1].char 79 | if lines[y][x + 1].attr != attr 80 | case @dock_contrast 81 | when DockContrast::DontDock 82 | return ch 83 | when DockContrast::Blend 84 | lines[y][x].attr = Colors.blend lines[y][x + 1].attr, attr 85 | # when DockContrast::Ignore 86 | # Note: ::Ignore needs no custom handler/code; it works as-is. 87 | end 88 | end 89 | angle |= BITWISE_R_ANGLE 90 | end 91 | 92 | if lines[y + 1]? && D_ANGLES.includes? lines[y + 1][x].char 93 | if lines[y + 1][x].attr != attr 94 | case @dock_contrast 95 | when DockContrast::DontDock 96 | return ch 97 | when DockContrast::Blend 98 | lines[y][x].attr = Colors.blend lines[y + 1][x].attr, attr 99 | # when DockContrast::Ignore 100 | # Note: ::Ignore needs no custom handler/code; it works as-is. 101 | end 102 | end 103 | angle |= BITWISE_D_ANGLE 104 | end 105 | 106 | # Experimental: fixes this situation: 107 | # +----------+ 108 | # | <-- empty space here, should be a T angle 109 | # +-------+ | 110 | # | | | 111 | # +-------+ | 112 | # | | 113 | # +----------+ 114 | # if U_ANGLES.includes? lines[y][x].char 115 | # if lines[y + 1] && D_ANGLES.includes? lines[y + 1][x].char 116 | # case @dock_contrast 117 | # when DockContrast::DontDock 118 | # if lines[y + 1][x].attr != attr 119 | # return ch 120 | # end 121 | # when DockContrast::Blend 122 | # lines[y][x].attr = Colors.blend lines[y + 1][x].attr, attr 123 | # end 124 | # angle |= BITWISE_D_ANGLE 125 | # end 126 | # end 127 | 128 | ANGLE_TABLE[angle]? || ch 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /CRYSTAL-WISHLIST.md: -------------------------------------------------------------------------------- 1 | # Wishlist for Crystal: 2 | 3 | 4 | ## Loops to support labels, using whatever syntax would be appropriate, e.g.: 5 | 6 | ```cr 7 | outer: while true 8 | while true 9 | break label: :outer 10 | end 11 | end 12 | ``` 13 | 14 | I know that it _is_ possible to make any code work without break that supports labels. 15 | But in some cases, the code is much more complex to write and follow in that way. 16 | Also, it forces the user to separate the code for "break" and "jump" into two 17 | places, which is unintuitive and the two locations are not always conveniently close 18 | to each other. 19 | 20 | ## Indirect initialization. This to work without throwing an error: 21 | 22 | ```cr 23 | class X 24 | @x : Bool 25 | 26 | def initialize 27 | set_vars 28 | end 29 | 30 | def set_vars 31 | @x = true 32 | end 33 | end 34 | 35 | X.new 36 | ``` 37 | 38 | This wouldn't necessarily have to support full indirect initialization. It 39 | would be enough that, if some methods are always/unconditionally called from `initialize`, 40 | the same checks that apply to `initialize` also apply to those methods, and to consider 41 | `@x` to be set. 42 | 43 | ## (Partly resolved) Using 'undefined' to expand into property's default value 44 | 45 | ```cr 46 | class X 47 | @var = "test" 48 | 49 | def initialize(@var = undefined) 50 | puts @var 51 | end 52 | 53 | end 54 | 55 | X.new # ==> "test" 56 | ``` 57 | 58 | This functionality appears to exist in a limited form. 59 | There is not a macro or keyword named `undefined`, but one can repeat the variable name: 60 | 61 | ``` 62 | def initialize(@var = @var) 63 | puts @var # ==> "test" 64 | end 65 | ``` 66 | 67 | However, it seems to work in `initialize` only, not in `new`. 68 | 69 | Thanks to @Blacksmoke16 for discussion / tip. 70 | 71 | A larger topic by Blacksmoke, relevant/related to this, is https://forum.crystal-lang.org/t/rfc-undefined-type/2695. 72 | 73 | ## (Resolved) Method overloads to not get overwritten by each other so easily: 74 | 75 | This currently doesn't work because the first overload gets completely overwritten: 76 | 77 | ```cr 78 | def bar(a = 0, b = 0, c = 0, d = 0) 79 | end 80 | 81 | def bar 82 | end 83 | 84 | bar(a: 1) 85 | ``` 86 | 87 | It results in: 88 | 89 | ``` 90 | In :7:1 91 | 92 | 7 | bar(a: 1) 93 | ^-- 94 | Error: no argument named 'a' 95 | 96 | Matches are: 97 | - bar() 98 | ``` 99 | 100 | @Sija created a ticket based on my initial report: 101 | 102 | https://github.com/crystal-lang/crystal/issues/10231 103 | 104 | The issue has since been resolved by @HertzDevil, but is not yet the default. 105 | To have the fix applied, you must invoke crystal with `crystal run -Dpreview_overload_order ...`. 106 | 107 | ## Type-safe `#==` operator: 108 | 109 | Due to default implementation of `#==` in `Value` and `Reference`, comparing 110 | anything to anything is allowed and returns false. This is very dangerous 111 | and leads to incorrect/invalid comparisons which always fail. 112 | 113 | https://github.com/crystal-lang/crystal/issues/10277 114 | 115 | Since it is probably too late to make this change in the language, the only 116 | thing that was possible to do was to add support to Ameba that Sija did: 117 | 118 | https://github.com/crystal-ameba/ameba/issues/237 119 | 120 | However, it only does literal-to-literal comparison checking and thus 121 | does not help the issue very much. 122 | 123 | The proper solution would be to have this supported at the language level in Crystal 3.0. 124 | For example, to have an operator like `#==?` that behaves like the current `#==` (i.e. returns false if 125 | arguments are not comparable). And then to change `#==` to a type-safe version so that it produces an 126 | error when there is no comparison defined between its arguments. 127 | 128 | @straight-shoota added this item (although with a different proposed solution) to the wishlist 129 | for the next Crystal major version (TODO: find a reference to it). 130 | 131 | ## API to expose a method to kill Fiber from outside code. 132 | 133 | This supposedly exists in non-public API, but I did not find it, be it public 134 | or not. 135 | 136 | ## Ability to create a Proc and partial from a method with named args: 137 | 138 | ```cr 139 | def f(a : Int32, b : Int32) 140 | end 141 | 142 | # Can't use named args at the moment: 143 | pf = ->f(a : Int32, b : Int32) 144 | 145 | # Nor this: 146 | ppf = pf.partial(b: 10) 147 | 148 | ``` 149 | 150 | A workaround was developed by @HertzDevil in https://github.com/crystal-lang/crystal/issues/11099 151 | 152 | ## Better subclassing in Procs: 153 | 154 | ```cr 155 | class A; end 156 | class B < A; end 157 | 158 | # This works: 159 | arr = [] of A 160 | arr << B.new 161 | 162 | # But with Procs it doesn't: 163 | arr2 = [] of Proc(A, Nil) 164 | arr2 << ->(e : B) { } 165 | ``` 166 | -------------------------------------------------------------------------------- /src/helpers.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | # Mixin containing helper functions 3 | module Helpers 4 | # Sorts array alphabetically by first letter of property 'name'. 5 | def asort(obj) 6 | obj.stable_sort! do |a, b| 7 | a_name = a.not_nil!.name.not_nil!.downcase 8 | b_name = b.not_nil!.name.not_nil!.downcase 9 | 10 | a_first_char = (a_name[0] == '.') ? a_name[1] : a_name[0] 11 | b_first_char = (b_name[0] == '.') ? b_name[1] : b_name[0] 12 | 13 | a_first_char <=> b_first_char 14 | end 15 | end 16 | 17 | # Sorts array numerically by property 'index' 18 | def hsort(obj) 19 | obj.sort_by! { |item| -item.index } 20 | end 21 | 22 | # Finds a file with name 'target' inside toplevel directory 'start'. 23 | # XXX Possibly replace with github: mlobl/finder 24 | def find_file(start, target) 25 | return nil if %w(/dev /sys /proc /net).includes?(start) 26 | 27 | files = begin 28 | # https://github.com/crystal-lang/crystal/issues/4807 29 | Dir.children start 30 | rescue e : Exception 31 | [] of String 32 | end 33 | 34 | files.each do |file| 35 | full = String.build do |str| 36 | str << start << File::SEPARATOR << file 37 | end 38 | 39 | return full if file == target 40 | 41 | stat = begin 42 | File.info full, follow_symlinks: false 43 | rescue e : Exception 44 | nil 45 | end 46 | 47 | if stat.directory? && !stat.symlink? 48 | found = find_file full, target 49 | return found if found 50 | end 51 | end 52 | 53 | nil 54 | end 55 | 56 | private def find(prefix, word) 57 | w0 = word[0].to_s 58 | 59 | file = String.build do |str| 60 | str << prefix << File::SEPARATOR << w0 61 | end 62 | 63 | return file if File.exists?(file) 64 | 65 | ch = w0.char_at(0).to_s.rjust(2, '0') 66 | 67 | file = String.build do |str| 68 | str << prefix << File::SEPARATOR << ch 69 | end 70 | 71 | return file if File.exists?(file) 72 | 73 | nil 74 | end 75 | 76 | # 77 | # NOTE Content-related functions below should stay here (instead of go to src/widget_content.cr) 78 | # since they're generic functions, not instance methods on Widget. 79 | # 80 | 81 | # Drops any >U+FFFF characters in the text. 82 | def drop_unicode(text) 83 | return "" if text.nil? || text.size == 0 84 | # TODO possibly find ready-made crystal method for this 85 | text.gsub(::Crysterm::Unicode::AllRegex, "??") # .gsub(@unicode.chars["combining"], "").gsub(@unicode.chars["surrogate"], "?"); 86 | end 87 | 88 | # Escapes text for tag-enabled elements where one does not want the tags enclosed in {...} to be treated specially, but literally. 89 | # 90 | # Example to print literal "{bold}{/bold}": 91 | # ''' 92 | # box.set_content("escaped content: " + escape("{bold}{/bold}")) 93 | # ''' 94 | def escape(text) 95 | text.gsub(/[{}]/) do |ch| 96 | case ch 97 | when "{" then "{open}" 98 | when "}" then "{close}" 99 | end 100 | end 101 | end 102 | 103 | # Strips text of "{...}" tags and SGR sequences and removes leading/trailing whitespaces 104 | def strip_tags(text : String) 105 | clean_tags(text).strip 106 | end 107 | 108 | # Strips text of {...} tags and SGR sequences 109 | def clean_tags(text) 110 | combined_regex = /(?:#{Crysterm::Widget::TAG_REGEX.source})|(?:#{Crysterm::Widget::SGR_REGEX.source})/ 111 | text.gsub(combined_regex) do |_, _| 112 | # No replacement needed, just removing matches 113 | end 114 | end 115 | 116 | # # Generates text tags based on the given style definition. 117 | # # Don't use unless you need to. 118 | # # ``` 119 | # # obj.generate_tags({"fg" => "lightblack"}, "text") # => "{light-black-fg}text{/light-black-fg}" 120 | # # ``` 121 | # def generate_tags(style : Hash(String, String | Bool) = {} of String => String | Bool) 122 | # open = "" 123 | # close = "" 124 | 125 | # (style).each do |key, val| 126 | # if (val.is_a? String) 127 | # val = val.sub(/^light(?!-)/, "light-") 128 | # val = val.sub(/^bright(?!-)/, "bright-") 129 | # open = "{" + val + "-" + key + "}" + open 130 | # close += "{/" + val + "-" + key + "}" 131 | # else 132 | # if val 133 | # open = "{" + key + "}" + open 134 | # close += "{/" + key + "}" 135 | # end 136 | # end 137 | # end 138 | 139 | # { 140 | # open: open, 141 | # close: close, 142 | # } 143 | # end 144 | 145 | # # :ditto: 146 | # def generate_tags(style : Hash(String, String | Bool), text : String) 147 | # v = generate_tags style 148 | # v[:open] + text + v[:close] 149 | # end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /src/screen_cursor.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # Terminal (not mouse) cursor 4 | # module Cursor 5 | include Macros 6 | 7 | # TODO - temporary until @cursor is moved to widget. This is extended because 8 | # Tput class does not have a property for color. 9 | class Cursor < Tput::Namespace::Cursor 10 | property style : Style = Style.new(char: '▮') 11 | end 12 | 13 | getter cursor = Cursor.new 14 | 15 | # Should all these functions go to tput? 16 | 17 | # Applies current cursor settings in `@cursor` to screen/display 18 | def apply_cursor 19 | c = @cursor 20 | # XXX Maybe checking for artificial makes sense here, but in blessed 21 | # it's not done. 22 | # if c.artificial? 23 | # render 24 | # else 25 | self.try do |d| 26 | c.shape.try { |shape| d.tput.cursor_shape shape, c.blink } 27 | # XXX consider a simpler structure than Style for cursor color? 28 | # XXX Blessed calls this: 29 | # c.style.fg.try { |color| d.tput.cursor_color Colors.convert color } 30 | # Why in our case that produces the following error when it's used: 31 | # Error: expected argument #1 to 'Tput#cursor_color' to be String or Tput::Namespace::Color, not Int32 32 | c.style.fg.try { |color| d.tput.cursor_color color } 33 | end 34 | # end 35 | c._set = true 36 | end 37 | 38 | # Sets cursor shape 39 | def cursor_shape(shape : Tput::CursorShape = Tput::CursorShape::Block, blink : Bool = false) 40 | @cursor.shape = shape 41 | @cursor.blink = blink 42 | @cursor._set = true 43 | tput.cursor_shape @cursor.shape, @cursor.blink 44 | end 45 | 46 | # XXX where does this belong? 47 | # if (!@_cursorBlink) 48 | # @_cursorBlink = setInterval(function() 49 | # if (!self.cursor.blink) return 50 | # self.cursor._state ^= 1 51 | # if (self.renders) self.render() 52 | # }, 500) 53 | # if (@_cursorBlink.unref) 54 | # @_cursorBlink.unref() 55 | # end 56 | # end 57 | 58 | # Resets cursor 59 | def cursor_reset 60 | @cursor.shape = Tput::CursorShape::Block 61 | @cursor.blink = false 62 | @cursor.style.bg = "#ffffff" 63 | @cursor._set = false 64 | tput.cursor_reset 65 | end 66 | 67 | # Sets cursor color 68 | def cursor_color(color : Tput::Color? = nil) 69 | # @cursor.style.bg = color.try do |c| 70 | # Tput::Color.new Colors.convert(c.value) 71 | # end 72 | # @cursor._set = true 73 | 74 | if @cursor.artificial? 75 | return true 76 | end 77 | 78 | # tput.cursor_color(@cursor.color.to_s.downcase) 79 | tput.cursor_color @cursor.style.fg 80 | end 81 | 82 | alias_previous reset_cursor 83 | 84 | # :nodoc: 85 | def _artificial_cursor_attr(cursor, attr = @default_attr) 86 | if cursor.shape.line? 87 | attr &= ~(0x1ff << 9) 88 | attr |= 7 << 9 89 | ch = '\u2502' 90 | elsif cursor.shape.underline? 91 | attr &= ~(0x1ff << 9) 92 | attr |= 7 << 9 93 | attr |= 2 << 18 94 | elsif cursor.shape.block? 95 | attr &= ~(0x1ff << 9) 96 | attr |= 7 << 9 97 | attr |= 8 << 18 98 | elsif cursor.shape.none? # XXX "lib/widgets/screen.js:2074 do they check for true, not none here? 99 | cattr = Widget.sattr cursor.style # XXX and some difference here 100 | # cattr = Colors.blend attr, cursor.style, (cursor.style.alpha || 0) 101 | if cursor.style.bold? || cursor.style.underline? || cursor.style.blink? || cursor.style.inverse? || !cursor.style.visible? 102 | attr &= ~(0x1ff << 18) 103 | attr |= ((cattr >> 18) & 0x1ff) << 18 104 | end 105 | if cursor.style.fg 106 | attr &= ~(0x1ff << 9) 107 | attr |= ((cattr >> 9) & 0x1ff) << 9 108 | end 109 | if cursor.style.bg 110 | attr &= ~(0x1ff << 0) 111 | attr |= cattr & 0x1ff 112 | end 113 | if cursor.style.char 114 | ch = cursor.style.char 115 | end 116 | end 117 | 118 | # TODO is never nil 119 | unless cursor.style.bg.nil? 120 | attr &= ~(0x1ff << 9) 121 | attr |= Colors.convert(cursor.style.bg) << 9 122 | end 123 | 124 | # Cell.new attr: attr, char: ch || ' ' 125 | {attr, ch} 126 | end 127 | 128 | # Shows cursor 129 | def show_cursor 130 | if @cursor.artificial? 131 | @cursor._hidden = false 132 | render if @renders > 0 133 | else 134 | tput.show_cursor 135 | end 136 | end 137 | 138 | # Hides cursor 139 | def hide_cursor 140 | if @cursor.artificial? 141 | @cursor._hidden = true 142 | render if @renders > 0 143 | else 144 | tput.hide_cursor 145 | end 146 | end 147 | 148 | # Re-enables and resets hardware cursor 149 | def cursor_reset 150 | if @cursor.artificial? 151 | @cursor.artificial = false 152 | end 153 | 154 | tput.cursor_reset 155 | end 156 | # end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /src/screen_attributes.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # Conversion between SGR sequences and Crysterm's attribute format 4 | 5 | # Converts an SGR string to our own attribute format. 6 | def attr2code(code, cur, dfl) 7 | flags = (cur >> 18) & 0x1ff 8 | fg = (cur >> 9) & 0x1ff 9 | bg = cur & 0x1ff 10 | # c 11 | # i 12 | 13 | code = code[2...-1].split(';') 14 | if !code[0]? || code[0].empty? 15 | code[0] = "0" 16 | end 17 | 18 | (0..code.size).each do |i| 19 | c = !code[i].empty? ? code[i].to_i : 0 20 | case c 21 | when 0 # normal 22 | bg = dfl & 0x1ff 23 | fg = (dfl >> 9) & 0x1ff 24 | flags = (dfl >> 18) & 0x1ff 25 | break 26 | when 1 # bold 27 | flags |= 1 28 | break 29 | when 22 30 | flags = (dfl >> 18) & 0x1ff 31 | break 32 | when 4 # underline 33 | flags |= 2 34 | break 35 | when 24 36 | flags = (dfl >> 18) & 0x1ff 37 | break 38 | when 5 # blink 39 | flags |= 4 40 | break 41 | when 25 42 | flags = (dfl >> 18) & 0x1ff 43 | break 44 | when 7 # inverse 45 | flags |= 8 46 | break 47 | when 27 48 | flags = (dfl >> 18) & 0x1ff 49 | break 50 | when 8 # invisible 51 | flags |= 16 52 | break 53 | when 28 54 | flags = (dfl >> 18) & 0x1ff 55 | break 56 | when 39 # default fg 57 | fg = (dfl >> 9) & 0x1ff 58 | break 59 | when 49 # default bg 60 | bg = dfl & 0x1ff 61 | break 62 | when 100 # default fg/bg 63 | fg = (dfl >> 9) & 0x1ff 64 | bg = dfl & 0x1ff 65 | break 66 | else # color 67 | if c == 48 && code[i + 1].to_i == 5 68 | i += 2 69 | bg = code[i].to_i 70 | break 71 | elsif c == 48 && code[i + 1].to_i == 2 72 | i += 2 73 | bg = Colors.match(code[i].to_i, code[i + 1].to_i, code[i + 2].to_i) 74 | if bg == -1 75 | bg = dfl & 0x1ff 76 | end 77 | i += 2 78 | break 79 | elsif c == 38 && code[i + 1].to_i == 5 80 | i += 2 81 | fg = code[i].to_i 82 | break 83 | elsif c == 38 && code[i + 1].to_i == 2 84 | i += 2 85 | fg = Colors.match(code[i].to_i, code[i + 1].to_i, code[i + 2].to_i) 86 | if fg == -1 87 | fg = (dfl >> 9) & 0x1ff 88 | end 89 | i += 2 # XXX Why ameba says this is no-op? 90 | break 91 | end 92 | if c >= 40 && c <= 47 93 | bg = c - 40 94 | elsif c >= 100 && c <= 107 95 | bg = c - 100 96 | bg += 8 97 | elsif c == 49 98 | bg = dfl & 0x1ff 99 | elsif c >= 30 && c <= 37 100 | fg = c - 30 101 | elsif c >= 90 && c <= 97 102 | fg = c - 90 103 | fg += 8 104 | elsif c == 39 105 | fg = (dfl >> 9) & 0x1ff 106 | elsif c == 100 107 | fg = (dfl >> 9) & 0x1ff 108 | bg = dfl & 0x1ff 109 | end 110 | break 111 | end 112 | end 113 | 114 | (flags << 18) | (fg << 9) | bg 115 | end 116 | 117 | # Converts our own attribute format to an SGR string. 118 | def code2attr(code) 119 | flags = (code >> 18) & 0x1ff 120 | fg = (code >> 9) & 0x1ff 121 | bg = code & 0x1ff 122 | 123 | String.build do |outbuf| 124 | outbuf << "\e[" 125 | 126 | # bold 127 | outbuf << "1;" if (flags & 1) != 0 128 | 129 | # underline 130 | outbuf << "4;" if (flags & 2) != 0 131 | 132 | # blink 133 | outbuf << "5;" if (flags & 4) != 0 134 | 135 | # inverse 136 | outbuf << "7;" if (flags & 8) != 0 137 | 138 | # invisible 139 | outbuf << "8;" if (flags & 16) != 0 140 | 141 | if bg != 0x1ff 142 | bg = _reduce_color(bg) 143 | if bg < 16 144 | bg < 8 ? outbuf << (bg + 40) << ';' : outbuf << (bg - 8 + 100) << ';' 145 | else 146 | outbuf << "48;5;" << bg << ';' 147 | end 148 | end 149 | 150 | if fg != 0x1ff 151 | fg = _reduce_color(fg) 152 | if fg < 16 153 | fg < 8 ? outbuf << (fg + 30) << ';' : outbuf << (fg - 8 + 90) << ';' 154 | else 155 | outbuf << "38;5;" << fg << ';' 156 | end 157 | end 158 | 159 | # If bytesize is 2, which is what we started with, it means nothing 160 | # was written, so we should in fact return an empty string. 161 | return "" if outbuf.bytesize == 2 162 | 163 | # Otherwise, something was written to the string. Since we know the 164 | # last char is ";", we go back one char and replace it with 'm', 165 | # then return that string. 166 | outbuf.back 1 167 | outbuf << 'm' 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /src/screen_focus.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # Widget focus. 4 | # 5 | # Broader in scope than mouse focus, since widget focus can be affected 6 | # by keys (Tab/Shift+Tab etc.) and operate without mouse. 7 | 8 | # Send focus events after mouse is enabled? 9 | property send_focus = false 10 | 11 | property _saved_focus : Widget? 12 | 13 | @history = [] of Widget 14 | @clickable = [] of Widget 15 | @keyable = [] of Widget 16 | 17 | # Returns the current/top element from the focus history list. 18 | def focused : Widget? 19 | @history[-1]? 20 | end 21 | 22 | # Makes `el` become the current/top element in the focus history list. 23 | def focus(el) 24 | focus_push el 25 | end 26 | 27 | # Focuses previous element in the list of focusable elements. 28 | def focus_previous 29 | focus_offset -1 30 | end 31 | 32 | # Focuses next element in the list of focusable elements. 33 | def focus_next 34 | focus_offset 1 35 | end 36 | 37 | # Saves/remembers the currently focused element. 38 | def save_focus 39 | @_saved_focus = focused 40 | end 41 | 42 | # Restores focus to the previously saved focused element. 43 | def restore_focus 44 | return unless sf = @_saved_focus 45 | sf.focus 46 | @_saved_focus = nil 47 | focused 48 | end 49 | 50 | # "Rewinds" focus to the most recent visible and attached element. 51 | # 52 | # As a side-effect, prunes the focus history list. 53 | def rewind_focus 54 | old = @history.pop 55 | 56 | el = @history.reverse.find { |el| el.screen && el.style.visible? } 57 | @history.clear 58 | 59 | return unless el 60 | 61 | if old 62 | old.emit Crysterm::Event::Blur 63 | end 64 | 65 | @history.push el 66 | _focus el, old 67 | el 68 | end 69 | 70 | # Focuses element `el`. Equivalent to `@display.focused = el`. 71 | def focus_push(el) 72 | old = @history.last? 73 | @history.shift if @history.size >= 10 # XXX non-configurable at the moment 74 | @history.push el 75 | _focus el, old 76 | end 77 | 78 | # Removes focus from the current element and focuses the element that was previously in focus. 79 | def focus_pop 80 | old = @history.pop 81 | if el = @history.last? 82 | _focus el, old 83 | end 84 | old 85 | end 86 | 87 | # Focuses an element by an offset in the list of focusable elements. 88 | # 89 | # E.g. `focus_offset 1` moves focus to the next focusable element. 90 | # `focus_offset 3` moves focus 3 focusable elements further. 91 | # 92 | # If the end of list of focusable elements is reached before the 93 | # item to focus is found, the search continues from the beginning. 94 | def focus_offset(offset) 95 | return if offset.zero? 96 | 97 | shown = @keyable.count { |el| el.screen && el.style.visible? } 98 | return if shown.zero? 99 | 100 | i = @keyable.index(focused) || -1 101 | i += offset 102 | 103 | i %= @keyable.size 104 | while !@keyable[i].screen || !@keyable[i].style.visible? 105 | i += offset >= 0 ? 1 : -1 106 | i %= @keyable.size 107 | end 108 | 109 | focus @keyable[i] 110 | end 111 | 112 | def _focus(cur : Widget, old : Widget? = nil) 113 | # Find a scrollable ancestor if we have one. 114 | el = cur 115 | while el = el.parent 116 | if el.scrollable? 117 | break 118 | end 119 | end 120 | 121 | # TODO is it valid that this isn't Widget? 122 | # unless el.is_a? Widget 123 | # raise "Unexpected" 124 | # end 125 | 126 | # TODO temporary 127 | cur.try &.state = WidgetState::Focused 128 | old.try &.state = WidgetState::Normal 129 | 130 | # If we're in a scrollable element, 131 | # automatically scroll to the focused element. 132 | if el && el.screen 133 | # Note: This is different from the other "visible" values - it needs the 134 | # visible height of the scrolling element itself, not the element within it. 135 | # NOTE why a/i values can be nil? 136 | visible = cur.screen.aheight - (el.atop || 0) - (el.itop || 0) - (el.abottom || 0) - (el.ibottom || 0) 137 | if cur.rtop < el.child_base 138 | # XXX remove 'if' when Screen is no longer parent of elements 139 | if el.is_a? Widget 140 | el.scroll_to cur.rtop 141 | end 142 | cur.screen.render 143 | elsif (cur.rtop + cur.aheight - cur.ibottom) > (el.child_base + visible) 144 | # Explanation for el.itop here: takes into account scrollable elements 145 | # with borders otherwise the element gets covered by the bottom border: 146 | # XXX remove 'if' when Screen is no longer parent of elements (Now it's not 147 | # so removing. Eventually remove this note altogether.) 148 | # if el.is_a? Widget 149 | el.scroll_to cur.rtop - (el.aheight - cur.aheight) + el.itop, true 150 | # end 151 | cur.screen.render 152 | end 153 | end 154 | 155 | if old 156 | old.emit Crysterm::Event::Blur, cur 157 | end 158 | 159 | cur.emit Crysterm::Event::Focus, old 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /src/widget/bigtext.cr: -------------------------------------------------------------------------------- 1 | require "./box" 2 | 3 | module Crysterm 4 | class Widget 5 | # Widget for displaying text in big font. 6 | # 7 | # Fonts can be converted from BDF to the required JSON format using https://github.com/chjj/ttystudio 8 | class BigText < Widget::Box 9 | property font 10 | property font_bold 11 | 12 | property ratio : Tput::Size = Tput::Size.new 0, 0 13 | property text = "" 14 | 15 | # TODO This widget isn't very useful as-is. 16 | # Add support font scaling, character for fg/bg, etc. 17 | 18 | # Normal font 19 | property normal : Hash(String, Array(Array(Int32))) # JSON::Any 20 | 21 | # Bold font 22 | property bold : Hash(String, Array(Array(Int32))) # JSON::Any 23 | 24 | # Currently active_font (points to normal or bold) 25 | property active_font : Hash(String, Array(Array(Int32))) # JSON::Any 26 | 27 | property _shrink_width : Bool = false 28 | property _shrink_height : Bool = false 29 | 30 | def initialize( 31 | @font = "#{__DIR__}/../fonts/ter-u14n.json", 32 | @font_bold = "#{__DIR__}/../fonts/ter-u14b.json", 33 | **box 34 | ) 35 | @normal = load_font font 36 | @bold = load_font font_bold 37 | 38 | box["content"]?.try do |c| 39 | @text = c 40 | end 41 | 42 | super **box 43 | 44 | @active_font = style.bold? ? @bold : @normal 45 | end 46 | 47 | def load_font(filename) 48 | data = JSON.parse File.read filename 49 | @ratio.width = data["width"].as_i 50 | @ratio.height = data["height"].as_i 51 | 52 | font = {} of String => Array(Array(Int32)) 53 | data.as_h.["glyphs"].as_h.each do |ch, data2| 54 | lines = data2.as_h.["map"].as_a.map &.as_s 55 | font[ch] = convert_letter ch, lines 56 | end 57 | 58 | # font.delete " " 59 | font 60 | end 61 | 62 | def convert_letter(ch, lines) 63 | while lines.size > @ratio.height 64 | lines.shift 65 | lines.pop 66 | end 67 | 68 | lines = lines.map do |line| 69 | chs = line.chars # line.split "" 70 | chs = chs.map do |ch2| 71 | (ch2 == ' ') ? 0 : 1 72 | end 73 | while chs.size < @ratio.width 74 | chs.push 0 75 | end 76 | chs 77 | end 78 | 79 | while lines.size < @ratio.height 80 | line = [] of Int32 81 | (0...@ratio.width).each do # |i| 82 | line.push 0 83 | end 84 | lines.push line 85 | end 86 | 87 | lines 88 | end 89 | 90 | def set_content(content : String) 91 | @content = "" 92 | @text = content || "" 93 | end 94 | 95 | def render 96 | if @width.nil? || @_shrink_width 97 | # D O: 98 | # if (awidth - iwidth < @ratio.width * @text.length + 1) 99 | @width = @ratio.width * @text.size + 1 100 | @_shrink_width = true 101 | # end 102 | end 103 | if @height.nil? || @_shrink_height 104 | # D O: 105 | # if (aheight - iheight < @ratio.height + 0) 106 | @height = @ratio.height 107 | @_shrink_height = true 108 | # end 109 | end 110 | coords = _render 111 | return unless coords 112 | 113 | lines = screen.lines 114 | left = coords.xi + ileft 115 | top = coords.yi + itop 116 | right = coords.xl - iright 117 | bottom = coords.yl - ibottom 118 | 119 | default_attr = sattr style 120 | bg = default_attr & 0x1ff 121 | fg = (default_attr >> 9) & 0x1ff 122 | flags = (default_attr >> 18) & 0x1ff 123 | attr = (flags << 18) | (bg << 9) | fg 124 | 125 | max_chars = Math.min @text.size, (right - left)//@ratio.width 126 | 127 | i = 0 128 | 129 | x = @align.right? ? (right - max_chars*@ratio.width) : left 130 | while i < max_chars 131 | ch = @text[i]?.try &.to_s 132 | break unless ch 133 | map = @active_font[ch]? || @active_font["?"] 134 | y = top 135 | while y < Math.min(bottom, top + @ratio.height) 136 | # XXX Not sure if this needs to be activated/used, or can be deleted 137 | # unless !lines[y]? 138 | # y += 1 139 | # next 140 | # end 141 | mline = map[y - top] 142 | next unless mline 143 | mx = 0 144 | while mx < @ratio.width 145 | mcell = mline[mx]? 146 | break if mcell.nil? 147 | 148 | lines[y]?.try(&.[x + mx]?).try do |cell| 149 | if style.fchar != ' ' 150 | cell.attr = default_attr 151 | cell.char = mcell == 1 ? style.fchar : style.char 152 | else 153 | cell.attr = mcell == 1 ? attr : default_attr 154 | cell.char = mcell == 1 ? ' ' : style.char 155 | end 156 | end 157 | 158 | mx += 1 159 | end 160 | lines[y]?.try &.dirty = true 161 | 162 | y += 1 163 | end 164 | 165 | x += @ratio.width 166 | i += 1 167 | end 168 | 169 | coords 170 | end 171 | end 172 | 173 | alias Bigtext = BigText 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /src/widget/progressbar.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Widget 3 | class ProgressBar < Input 4 | property filled : Int32 = 0 5 | property value : Int32 = 0 6 | property orientation : Tput::Orientation = :horizontal 7 | 8 | # TODO Add new options: 9 | # min value and max value 10 | # step of increase 11 | # does it wrap around? 12 | # does it print value and/or percentage 13 | # can it auto-resize based on amount 14 | # always track of how many % a certain value is 15 | # Ability to always display filled % or amount 16 | 17 | # XXX Change this to enabled? later. 18 | property? keys : Bool = true 19 | property? mouse : Bool = false 20 | 21 | def initialize( 22 | @filled = 0, 23 | @keys = true, 24 | @mouse = false, 25 | @orientation = @orientation, 26 | **input 27 | ) 28 | super **input 29 | 30 | @value = @filled 31 | 32 | if @keys 33 | handle Crysterm::Event::KeyPress 34 | end 35 | 36 | if @mouse 37 | # XXX ... 38 | end 39 | end 40 | 41 | def render 42 | ret = _render 43 | return unless ret 44 | 45 | self.style.border.try &.adjust(ret) 46 | 47 | xi = ret.xi 48 | xl = ret.xl 49 | yi = ret.yi 50 | yl = ret.yl 51 | 52 | if @orientation.horizontal? 53 | xl = xi + ((xl - xi) * (@filled / 100)).to_i 54 | else 55 | yi = yi + ((yl - yi) - (((yl - yi) * (@filled / 100)).to_i)) 56 | end 57 | 58 | # NOTE We invert fg and bg here, so that progressbar's filled value would be 59 | # rendered using foreground color. This is different than blessed, and: 60 | # 1) Arguably more correct as far as logic goes 61 | # 2) And also allows the widget to show filled value in a way which is visible 62 | # even if style.bar is not specifically defined 63 | # Further explanation for (2): 64 | # In Blessed, style.bar does not automatically fallback to style. This then causes the 65 | # default for bar (filled value) to be black color. If the bg color of the rest is different, 66 | # filled value is visible. If it is also black (and it is by default?), then filled 67 | # value appears invisible. (And also there is no option to display the percentage as a 68 | # number inside the widget. 69 | # In Crysterm, style.bar (and all other sub-styles) do fallback to main style. This then 70 | # causes the filled value's bg and default bg to always be equal if style.bar is not 71 | # specifically defined. And thus it makes filled value show in even less cases than it 72 | # does in blessed. By reverting bg/fg like we do here, we solve this problem in a very 73 | # elegant way. 74 | default_attr = sattr style.bar, style.bar.bg, style.bar.fg 75 | 76 | # TODO Is this approach with using drawing routines valid, or it would be 77 | # better that we do this in-memory only here? 78 | screen.fill_region default_attr, style.pchar, xi, xl, yi, yl 79 | 80 | # Why here the formatted content is only in @_pcontent, while in blessed 81 | # it appears to be in `this.content` directly? 82 | if (pc = @_pcontent) && !pc.empty? 83 | screen.lines[yi]?.try do |line| 84 | pc.each_char_with_index do |c, i| 85 | line[xi + i]?.try do |cell| 86 | cell.char = c 87 | end 88 | end 89 | line.dirty = true 90 | end 91 | end 92 | 93 | ret 94 | end 95 | 96 | def progress(filled_delta) 97 | f = @filled + filled_delta 98 | f = 0 if f < 0 99 | f = 100 if f > 100 100 | @filled = f 101 | if f == 100 102 | emit Crysterm::Event::Complete 103 | end 104 | @value = @filled 105 | end 106 | 107 | def progress=(filled) 108 | @filled = 0 109 | progress filled 110 | end 111 | 112 | def reset 113 | emit Crysterm::Event::Reset 114 | @filled = 0 115 | @value = @filled 116 | end 117 | 118 | def on_keypress(e) 119 | # Since the keys aren't conflicting, support both regardless 120 | # of orientation. 121 | # case @orientation 122 | # when Tput::Orientation::Vertical 123 | # back_keys = [Tput::Key::Down] 124 | # back_chars = ['j'] 125 | # forward_keys = [Tput::Key::Up] 126 | # forward_chars = ['k'] 127 | # else #when Tput::Orientation::Horizontal 128 | # back_keys = [Tput::Key::Left] 129 | # back_chars = ['h'] 130 | # forward_keys = [Tput::Key::Right] 131 | # forward_chars = ['l'] 132 | # end 133 | 134 | back_keys = [Tput::Key::Left, Tput::Key::Down] 135 | back_chars = ['h', 'j'] 136 | forward_keys = [Tput::Key::Right, Tput::Key::Up] 137 | forward_chars = ['l', 'k'] 138 | 139 | if back_keys.includes?(e.key) || back_chars.includes?(e.char) 140 | progress -5 141 | screen.render 142 | return 143 | elsif forward_keys.includes?(e.key) || forward_chars.includes?(e.char) 144 | progress 5 145 | screen.render 146 | return 147 | end 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /src/event.cr: -------------------------------------------------------------------------------- 1 | require "event_handler" 2 | require "tput" 3 | 4 | module Crysterm 5 | # Collection of all events used by Crysterm 6 | module Event 7 | include EventHandler 8 | 9 | # Events currently unused have been commented. Uncomment on first use. 10 | 11 | # Emitted when widget is attached to a screen directly or somewhere in its ancestry 12 | event Attach, object : EventHandler 13 | 14 | # Emitted when widget is detached from a screen directly or somewhere in its ancestry 15 | event Detach, object : EventHandler 16 | 17 | # Emitted when widget gains a new parent 18 | event Reparent, widget : Widget? 19 | 20 | # Emitted when widget is added to parent 21 | event Adopt, widget : Widget 22 | 23 | # Emitted when widget is removed from its current parent 24 | event Remove, widget : Widget 25 | 26 | # Emitted when Widget is destroyed 27 | event Destroy 28 | 29 | # Emitted when widget focuses. Requires terminal supporting the focus protocol. 30 | event Focus, el : Widget? = nil 31 | 32 | # Emitted when widget goes out of focus. Requires terminal supporting the focus protocol. 33 | event Blur, el : Widget? = nil 34 | 35 | # Emitted when widget scrolls 36 | event Scroll 37 | 38 | # # Emitted on some data 39 | # event Data, data : String 40 | 41 | # # Emitted on a warning event 42 | # event Warning, message : String 43 | 44 | # Emitted when screen is resized. 45 | event Resize, size : Tput::Namespace::Size? = nil 46 | 47 | # Emitted when object is hidden 48 | event Hide 49 | 50 | # Emitted when object is shown 51 | event Show 52 | 53 | # Emitted at the beginning of rendering/drawing. 54 | event PreRender 55 | 56 | # Emitter at the end or rendering/drawing. 57 | event Rendered 58 | 59 | # # event PostRender 60 | 61 | # # Emitted at the end of drawing. Currently disabled/unused. 62 | # # event Draw 63 | 64 | # Emitted after Widget's content is defined 65 | event SetContent 66 | 67 | # Emitted after Widget's content is parsed 68 | event ParsedContent 69 | 70 | # Emitted on mouse click 71 | event Click 72 | 73 | # Emitted on button press 74 | event Press 75 | 76 | # Emitted on checkbox checked 77 | event Check, value : Bool 78 | 79 | # Emitted on checkbox unchecked 80 | event UnCheck, value : Bool 81 | 82 | # Emitted when Widget's position is changed 83 | event Move 84 | 85 | # Emitted on something being completed (e.g. progressbar reaching 100%) 86 | event Complete 87 | 88 | # # Emitted on something being reset (e.g. progressbar reset to 0%) 89 | # event Reset 90 | 91 | # Emitted on value submitted (e.g. in text forms) 92 | event Submit, value : String 93 | 94 | # Emitted on value canceled (e.g. in text forms) 95 | event Cancel, value : String 96 | 97 | event Action, value : String 98 | 99 | # Emitted on creation of a list item 100 | event CreateItem 101 | 102 | # Emitted on addition of a list item to list 103 | event AddItem 104 | # Emitted on removal of a list item 105 | event RemoveItem 106 | # Emitted on re-set/re-definition of list items 107 | event SetItem 108 | # :ditto: 109 | event SetItems 110 | 111 | event CancelItem, item : Widget::Box, index : Int32 112 | event ActionItem, item : Widget::Box, index : Int32 113 | 114 | # Event emitted when a new log line intended for `Widget::Log` is issued 115 | event Log, text : String 116 | # NOTE In Blessed, this is called `log` and `Widget::Log`. It's been renamed 117 | # in Crysterm not to conflict with `Log` coming from logger. 118 | 119 | # Emitted on selection of an item in list 120 | event SelectItem, item : Widget::Box, index : Int32 121 | 122 | # Emitted when an Action is Triggered 123 | event Triggered 124 | 125 | # Emitted when a Widget or Action are hovered 126 | event Hovered 127 | 128 | # # event Key, key : ::Tput::Key 129 | 130 | # Individual key events emitted on specific key presses. This is used when 131 | # the caller does not want to listen for everything on `Event::KeyPress` (i.e. 132 | # all keypresses), but when they want explicit keys like 133 | # `Event::KeyPress::CtrlQ`. 134 | class KeyPress < EventHandler::Event 135 | property char : Char 136 | property key : ::Tput::Key? 137 | property sequence : Array(Char) 138 | property? accepted : Bool = false 139 | 140 | def initialize(char, @key = nil, @sequence = [char]) 141 | @char = char 142 | end 143 | 144 | # Accepts event and causes it to stop propagating. 145 | def accept 146 | @accepted = true 147 | end 148 | 149 | # Ignores event and causes it to continue propagating. 150 | def ignore 151 | @accepted = false 152 | end 153 | 154 | # This macro takes all enum members from Tput::Key 155 | # and creates a `KeyPress::` event for them, 156 | # such as `Event::KeyPress::CtrlQ`. 157 | # 158 | # This is done as a convenience, so that users would 159 | # not have to listen for all keypresses and then 160 | # manually check for particular keys every time. 161 | KEYS = {} of ::Tput::Key => self.class 162 | {% for m in ::Tput::Key.constants %} 163 | class {{m.id}} < self; end 164 | KEYS[ ::Tput::Key::{{m.id}} ] = {{m.id}} 165 | {% end %} 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /examples/tech-demo.cr: -------------------------------------------------------------------------------- 1 | require "../src/crysterm" 2 | 3 | module Crysterm 4 | include Tput::Namespace 5 | include Widgets 6 | 7 | s = Screen.new always_propagate: [Tput::Key::CtrlQ], title: "Crysterm Tech Demo" 8 | b = layout = Layout.new( 9 | parent: s, 10 | top: 0, 11 | left: 0, 12 | width: "100%", 13 | height: "100%", 14 | layout: LayoutType::Grid, 15 | overflow: Overflow::Ignore, 16 | ) 17 | 18 | # b.focus 19 | 20 | box = Box.new( 21 | parent: layout, 22 | width: 36, 23 | height: 18, 24 | style: Style.new(border: BorderType::Line), 25 | content: "Plain box with some content." 26 | ) 27 | 28 | button = Button.new( 29 | parent: layout, 30 | width: 36, 31 | height: 3, 32 | align: AlignFlag::HCenter, 33 | content: "Click me, I am a button.", 34 | style: Style.new( 35 | bg: "blue", 36 | fg: "yellow", 37 | border: Border.new( 38 | type: BorderType::Line, 39 | bg: "blue" 40 | ), 41 | shadow: true 42 | ) 43 | ) 44 | 45 | checkboxes = Box.new( 46 | parent: layout, 47 | width: 18, 48 | height: 18 49 | ) 50 | checkbox1 = Checkbox.new parent: checkboxes, content: "Checkbox 1", top: 0 51 | checkbox2 = Checkbox.new parent: checkboxes, content: "Checkbox 2", top: 2 52 | checkbox3 = Checkbox.new parent: checkboxes, content: "Checkbox 3", top: 4 53 | checkbox4 = Checkbox.new parent: checkboxes, content: "Checkbox 4", top: 6 54 | 55 | radioset = RadioSet.new parent: layout, width: 20, height: 18 56 | radio1 = RadioButton.new parent: radioset, content: "Radio button 1", top: 0 57 | radio2 = RadioButton.new parent: radioset, content: "Radio button 2", top: 2 58 | radio3 = RadioButton.new parent: radioset, content: "Radio button 3", top: 4 59 | radio4 = RadioButton.new parent: radioset, content: "Radio button 4", top: 6 60 | 61 | progressbar = ProgressBar.new \ 62 | parent: layout, 63 | content: "{center}Progress bar{/center}", 64 | parse_tags: true, 65 | filled: 50, 66 | width: 36, 67 | height: 3, 68 | style: Style.new( 69 | fg: "yellow", 70 | bg: "magenta", 71 | border: Border.new( 72 | fg: "#ffffff" 73 | ), 74 | shadow: true, 75 | ) 76 | 77 | loading = Loading.new \ 78 | parent: layout, 79 | align: AlignFlag::HCenter, 80 | width: 36, 81 | height: 18, 82 | icons: ["Preparing", "Loading", "Processing", "Saving", "Analyzing"], 83 | content: "Please wait...", 84 | style: Style.new(alpha: true, fg: "white", bg: "black", border: Border.new(fg: "white", bg: "black")) 85 | 86 | question = Question.new \ 87 | parent: layout, 88 | content: "Question: {bold}HOT{/bold} or NOT?", 89 | # hidden: false, 90 | parse_tags: true, 91 | width: 36, 92 | height: 9, 93 | style: Style.new( 94 | alpha: true, 95 | fg: "yellow", 96 | bg: "magenta", 97 | border: Border.new( 98 | fg: "#ffffff" 99 | ), 100 | shadow: true, 101 | ) 102 | question.ask { } 103 | 104 | # overlayimage = OverlayImage.new \ 105 | # parent: layout, 106 | # width: 36, 107 | # height: 18, 108 | # style: Style.new( 109 | # fg: "yellow", 110 | # bg: "magenta", 111 | # border: Border.new( 112 | # fg: "#ffffff" 113 | # ), 114 | # shadow: false, 115 | # ) 116 | 117 | bigtext = BigText.new( 118 | parent: layout, 119 | width: 36, 120 | height: 18, 121 | style: Style.new(border: true), 122 | content: "Big" 123 | ) 124 | 125 | textarea = TextArea.new( 126 | parent: layout, 127 | width: 36, 128 | input_on_focus: true, 129 | height: 18, 130 | style: Style.new(border: true), 131 | content: "" 132 | ) 133 | 134 | textbox = TextBox.new( 135 | parent: layout, 136 | width: 36, 137 | height: 3, 138 | style: Style.new(border: true), 139 | content: "TextBox. One-line element." 140 | ) 141 | 142 | boxtp2 = Box.new( 143 | parent: s, 144 | width: 60, 145 | height: 16, 146 | top: 18, 147 | left: 160, 148 | content: "Hello, World! See translucency and shadow.", 149 | style: Style.new(bg: "#870087", border: BorderType::Bg, shadow: Shadow.new(true, true, false, false)) 150 | ) 151 | boxtp1 = Box.new( 152 | parent: s, 153 | top: 14, 154 | left: 150, 155 | width: 60, 156 | height: 14, 157 | content: "See indeed.", 158 | style: Style.new(bg: "#729fcf", alpha: true, border: true, shadow: true) 159 | ) 160 | 161 | loading2 = Loading.new \ 162 | parent: layout, 163 | align: AlignFlag::Right, 164 | compact: true, 165 | interval: 0.2.seconds, 166 | width: 36, 167 | height: 3, 168 | content: "In progress!...", 169 | style: Style.new(border: true) 170 | 171 | s.on(Event::KeyPress) do |e| 172 | # e.accept 173 | if e.key == ::Tput::Key::CtrlQ || e.char == 'q' 174 | s.destroy 175 | exit 176 | end 177 | end 178 | 179 | s.render 180 | 181 | textv = "TextArea. This is a multi-line user input enabled widget with automatic content wrapping. " + 182 | "There is a lot of text that can fit it, when the terminal doesn't use too big font." 183 | textboxv = " This will add more text to textbox and always show only visible portion." 184 | 185 | textarea.focus 186 | loading.start 187 | loading2.start 188 | i = 0 189 | spawn do 190 | loop do 191 | [checkbox1, checkbox2, checkbox3, checkbox4][i % 4].toggle 192 | [radio1, radio2, radio3, radio4][i % 4].check 193 | progressbar.filled += 5 194 | if progressbar.filled > 100 195 | progressbar.filled = 0 196 | end 197 | 198 | if ch = textv[i]? 199 | textarea.emit Event::KeyPress.new ch 200 | else 201 | i = 0 202 | end 203 | 204 | new_letter = textboxv[i]? 205 | if new_letter 206 | textbox.value += new_letter 207 | else 208 | textbox.value = "" 209 | end 210 | i += 1 211 | Fiber.yield 212 | sleep 0.2.seconds 213 | end 214 | end 215 | 216 | s.render 217 | 218 | s.exec 219 | end 220 | -------------------------------------------------------------------------------- /src/screen_rendering.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # Things related to rendering (setting up memory state for display) 4 | 5 | DEFAULT_ATTR = ((0 << 18) | (0x1ff << 9)) | 0x1ff 6 | DEFAULT_CHAR = ' ' 7 | 8 | # Note: Disabled since nothing uses it. 9 | # class BorderStop 10 | # property? yes = false 11 | # property xi : Int32? 12 | # property xl : Int32? 13 | # end 14 | 15 | # Note: Disabled since nothing uses it. 16 | # class BorderStops < Hash(Int32, BorderStop) 17 | # def []=(idx : Int32, arg) 18 | # self[idx]? || (self[idx] = BorderStop.new) 19 | # case arg 20 | # when Bool 21 | # self[idx].yes = arg 22 | # else 23 | # self[idx].xi = arg.xi 24 | # self[idx].xl = arg.xl 25 | # end 26 | # end 27 | # end 28 | 29 | @render_flag : Atomic(UInt8) = Atomic.new 0u8 30 | @render_channel : Channel(Bool) = Channel(Bool).new 31 | property interval : Float64 = 1/29 32 | 33 | def schedule_render 34 | _old, succeeded = @render_flag.compare_and_set 0, 1 35 | if succeeded 36 | @render_channel.send true 37 | end 38 | end 39 | 40 | class Average < Deque(Int32) 41 | def avg(value) 42 | shift if size == @capacity 43 | push value 44 | sum // size 45 | end 46 | end 47 | 48 | @rps = Average.new 30 49 | @dps = Average.new 30 50 | @fps = Average.new 30 51 | 52 | def render_loop 53 | loop do 54 | if @render_channel.receive 55 | sleep @interval.seconds 56 | end 57 | _render 58 | if @render_flag.lazy_get == 2 59 | break 60 | else 61 | @render_flag.swap 0 62 | end 63 | end 64 | end 65 | 66 | property _border_stops = {} of Int32 => Bool 67 | 68 | # Rendering optimizations. 69 | property optimization : OptimizationFlag = OptimizationFlag::None 70 | 71 | # XXX move somewhere else? 72 | # Default cell attribute 73 | property default_attr : Int32 = DEFAULT_ATTR 74 | 75 | # XXX move somewhere else? 76 | # Default cell character 77 | property default_char : Char = DEFAULT_CHAR 78 | 79 | # Automatically "dock" borders with other elements instead of overlapping, 80 | # depending on position. 81 | # These border-overlapped elements: 82 | # ┌─────────┌─────────┐ 83 | # │ box1 │ box2 │ 84 | # └─────────└─────────┘ 85 | # Become: 86 | # ┌─────────┬─────────┐ 87 | # │ box1 │ box2 │ 88 | # └─────────┴─────────┘ 89 | property? dock_borders : Bool = false 90 | 91 | # Dockable borders will not dock if the colors or attributes are different. 92 | # This option will allow docking regardless. It may produce odd looking 93 | # multi-colored borders. 94 | @dock_contrast = DockContrast::Blend 95 | 96 | property lines = Array(Row).new 97 | property olines = Array(Row).new 98 | 99 | def _dock_borders 100 | lines = @lines 101 | stops = @_border_stops 102 | 103 | # D O: 104 | # keys, stop 105 | # keys = Object.keys(this._borderStops) 106 | # .map(function(k) { return +k; }) 107 | # .sort(function(a, b) { return a - b; }) 108 | # 109 | # for (i = 0; i < keys.length; i++) 110 | # y = keys[i] 111 | # if (!lines[y]) continue 112 | # stop = this._borderStops[y] 113 | # for (x = stop.xi; x < stop.xl; x++) 114 | 115 | stops = stops.keys.map(&.to_i).sort! 116 | 117 | stops.each do |y| 118 | next unless lines[y]? 119 | 120 | awidth.times do |x| 121 | ch = lines[y][x].char 122 | if ANGLES.includes? ch 123 | lines[y][x].char = _get_angle lines, x, y 124 | lines[y].dirty = true 125 | end 126 | end 127 | end 128 | end 129 | 130 | # Delayed render (user render) 131 | def render 132 | schedule_render 133 | end 134 | 135 | # Real render 136 | def _render # (draw = true) #@@auto_draw) 137 | t1 = Time.monotonic 138 | 139 | emit Crysterm::Event::PreRender 140 | 141 | @_border_stops.clear 142 | 143 | # TODO: Possibly get rid of .dirty altogether. 144 | # TODO: Could possibly drop .dirty and just clear the `lines` buffer every 145 | # time before a screen.render. This way clearRegion doesn't have to be 146 | # called in arbitrary places for the sake of clearing a spot where an 147 | # element used to be (e.g. when an element moves or is hidden). There could 148 | # be some overhead though. 149 | # screen.clearRegion(0, this.cols, 0, this.rows); 150 | @_ci = 0 151 | @children.each do |el| 152 | el.index = @_ci 153 | @_ci += 1 154 | el.render 155 | end 156 | @_ci = -1 157 | 158 | _dock_borders if @dock_borders 159 | 160 | t2 = Time.monotonic 161 | 162 | draw 163 | 164 | # XXX Workaround to deal with cursor pos before the screen 165 | # has rendered and lpos is not reliable (stale). 166 | # Only some elements have this function; for others it's a noop. 167 | focused.try do |focused_widget| 168 | focused_widget._update_cursor(true) 169 | focused_widget.emit(Crysterm::Event::Focus) 170 | end 171 | 172 | @renders += 1 173 | 174 | emit Crysterm::Event::Rendered 175 | 176 | t3 = Time.monotonic 177 | 178 | if pos = @show_fps 179 | ps = {1 // (t2 - t1).total_seconds, 1 // (t3 - t2).total_seconds, 1 // (t3 - t1).total_seconds} 180 | 181 | tput.save_cursor 182 | tput.pos pos 183 | tput._print { |io| io << "R/D/FPS: #{ps[0]}/#{ps[1]}/#{ps[2]}" } 184 | if @show_avg 185 | tput._print { |io| io << " (#{@rps.avg(ps[0])}/#{@dps.avg(ps[1])}/#{@fps.avg(ps[2])})" } 186 | end 187 | tput.restore_cursor 188 | end 189 | end 190 | 191 | # TODO Instead of self, this should just return an object which reports the position 192 | # like LPos. But until screen is always from (0,0) to (height,width) that's not necessary. 193 | def last_rendered_position 194 | self 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /src/widget.cr: -------------------------------------------------------------------------------- 1 | require "./event" 2 | require "./helpers" 3 | 4 | require "./mixin/children" 5 | require "./mixin/pos" 6 | require "./mixin/uid" 7 | require "./mixin/data" 8 | 9 | require "./widget_children" 10 | require "./widget_index" 11 | require "./widget_position" 12 | require "./widget_size" 13 | require "./widget_decoration" 14 | require "./widget_visibility" 15 | require "./widget_content" 16 | require "./widget_scrolling" 17 | require "./widget_rendering" 18 | require "./widget_interaction" 19 | require "./widget_screenshot" 20 | require "./widget_label" 21 | 22 | module Crysterm 23 | class Widget 24 | include EventHandler 25 | include Macros 26 | include Mixin::Name 27 | include Mixin::Uid 28 | include Mixin::Pos 29 | include Mixin::Style 30 | include Mixin::Data 31 | 32 | # Widget's parent `Widget`, if any. 33 | property parent : Widget? 34 | # (This must be defined here rather than in src/mixin/children.cr because classes 35 | # which have children do not necessarily also have a parent, e.g. `Screen`.) 36 | 37 | # Screen owning this element, forced to non-nil at time of access. 38 | # Each element must belong to a Screen if it is to be rendered/displayed anywhere. 39 | # If you just want to test for screen being set, use `#screen?`. 40 | property! screen : ::Crysterm::Screen? 41 | 42 | # :ditto: 43 | getter? screen 44 | 45 | # XXX FIX by removing at some point 46 | # Used only for lists. The reason why it hasn't been replaced with is_a?(List) 47 | # already is because maybe someone would want this to be true even if not 48 | # inheriting from List. 49 | property _is_list = false 50 | 51 | def initialize( 52 | parent = nil, 53 | *, 54 | 55 | @name = @name, 56 | @screen = @screen, 57 | 58 | @left = @left, 59 | @top = @top, 60 | @right = @right, 61 | @bottom = @bottom, 62 | @width = @width, 63 | @height = @height, 64 | @resizable = @resizable, 65 | 66 | visible = nil, 67 | @fixed = @fixed, 68 | @align = @align, 69 | @overflow = @overflow, 70 | 71 | @scrollbar = @scrollbar, 72 | # TODO Make it configurable which side it appears on etc. 73 | @track = @track, 74 | # XXX Should this whole section of 5 properties be in Style? 75 | 76 | content = "", 77 | @parse_tags = @parse_tags, 78 | @wrap_content = @wrap_content, 79 | 80 | label = nil, 81 | hover_text = nil, 82 | # TODO Unify naming label[_text]/hover[_text] 83 | 84 | scrollable = nil, 85 | @always_scroll = @always_scroll, 86 | # hover_bg=nil, 87 | @draggable = @draggable, 88 | focused = false, 89 | @focus_on_click = @focus_on_click, 90 | @keys = @keys, 91 | @vi = @vi, 92 | input = nil, 93 | style = nil, 94 | @styles = @styles, 95 | 96 | # Final, misc settings 97 | @index = -1, 98 | children = [] of Widget 99 | ) 100 | # $ = _ = JSON/YAML::Any 101 | 102 | style.try { |v| @style = v } 103 | scrollable.try { |v| @scrollable = v } 104 | input.try { |v| @input = v } 105 | visible.try { |v| self.style.visible = v } 106 | 107 | # This just defines which Screen it is all linked to. 108 | # (Until we make `screen` fully optional) 109 | @screen ||= determine_screen 110 | 111 | # And this takes care of parent hierarchy. Parent arg as passed 112 | # to this function can be a Widget or Screen. 113 | parent.try &.append self 114 | 115 | children.each do |child| 116 | append child 117 | end 118 | 119 | set_content content, true 120 | label.try do |t| 121 | set_label t, "left" 122 | end 123 | hover_text.try do |t| 124 | set_hover t 125 | end 126 | 127 | # on(AddHandlerEvent) { |wrapper| } 128 | on(Crysterm::Event::Resize) { process_content } 129 | on(Crysterm::Event::Attach) { process_content } 130 | # on(Crysterm::Event::Detach) { @lpos = nil } # XXX D O or E O? 131 | 132 | if s = scrollbar 133 | # Allow controlling of the scrollbar via the mouse: 134 | # TODO 135 | # if @mouse 136 | # # TODO 137 | # end 138 | end 139 | 140 | # # TODO same as above 141 | # if @mouse 142 | # end 143 | 144 | if @scrollable 145 | # XXX also remove handler when scrollable is turned off? 146 | on(Crysterm::Event::ParsedContent) do 147 | _recalculate_index 148 | end 149 | 150 | _recalculate_index 151 | end 152 | 153 | focus if focused 154 | end 155 | 156 | def destroy 157 | @children.each do |c| 158 | c.destroy 159 | end 160 | remove_from_parent 161 | emit Crysterm::Event::Destroy 162 | end 163 | 164 | def determine_screen 165 | scr = if Screen.total <= 1 166 | # This will use the first screen or create one if none created yet. 167 | # (Auto-creation helps writing scripts with less code.) 168 | Screen.global true 169 | elsif s = @parent 170 | while s && !(s.is_a? Screen) 171 | s = s.parent_or_screen 172 | end 173 | if s.is_a? Screen 174 | s 175 | # else 176 | # raise Exception.new("No active screen found in parent chain.") 177 | end 178 | # elsif Screen.total > 0 179 | # #Screen.instances[-1] 180 | # Screen.instances[0] 181 | # # XXX For the moment we use the first screen instead of the last one, 182 | # as global, so same here - we just return the first one: 183 | end 184 | 185 | unless scr 186 | scr = Screen.global 187 | end 188 | 189 | unless scr 190 | raise Exception.new("No Screen found anywhere. Create one with Screen.new") 191 | end 192 | 193 | scr 194 | end 195 | 196 | # Returns parent `Widget` (if any) or `Screen` to which the widget may be attached. 197 | # If the widget already is `Screen`, returns `nil`. 198 | def parent_or_screen 199 | return self if Screen === self 200 | (@parent || screen).not_nil! 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /src/screen_interaction.cr: -------------------------------------------------------------------------------- 1 | module Crysterm 2 | class Screen 3 | # File related to interaction on the display 4 | 5 | # Is the focused element grab and receiving all keypresses? 6 | property? grab_keys = false 7 | 8 | # Are keypresses being propagated further, or (except ignored ones) not propagated? 9 | property? propagate_keys = true 10 | 11 | # Array of keys to ignore when keys are locked or grabbed. Useful for defining 12 | # keys that will always execute their action (e.g. exit a program) regardless of 13 | # whether keys are propagate. 14 | property always_propagate = Array(Tput::Key).new 15 | 16 | @_keys_fiber : Fiber? 17 | 18 | # XXX Maybe in the future this would not be just `Tput::Key`s (which indicate 19 | # special keys), but also chars (ordinary letters) as well as sequences (arbitrary 20 | # sequences of chars and keys). 21 | 22 | # Sets up IO listeners for keyboard (and mouse, but mouse is currently unsupported). 23 | def listen 24 | # D O: 25 | # Potentially reset screen title on exit: 26 | # if !tput.rxvt? 27 | # if !tput.vte? 28 | # tput.set_title_mode_feature 3 29 | # end 30 | # manipulate_window(21) { |err, data| 31 | # return if err 32 | # @_original_title = data.text 33 | # } 34 | # end 35 | 36 | # Listen for keys/mouse on input 37 | # if (@tput.input._our_input == 0) 38 | # @tput.input._out_input = 1 39 | listen_keys 40 | # listen_mouse # TODO 41 | # else 42 | # @tput.input._our_input += 1 43 | # end 44 | 45 | # TODO Do this if it's possible to get resize events on individual IOs. 46 | # Listen for resize on output 47 | # if (@output._our_output==0) 48 | # @output._our_output = 1 49 | # listen_output 50 | # else 51 | # @output._our_output += 1 52 | # end 53 | end 54 | 55 | # Starts emitting `Event::KeyPress` events on key presses. 56 | # 57 | # Keys are listened for in a separate `Fiber`. There should be at most 1. 58 | def listen_keys 59 | return if @_keys_fiber 60 | @_keys_fiber = spawn { 61 | tput.listen do |char, key, sequence| 62 | emit Crysterm::Event::KeyPress.new char, key, sequence 63 | end 64 | } 65 | end 66 | 67 | # Disabled since they exist, but nothing calls them within blessed: 68 | # def enable_keys(el = nil) 69 | # _listen_keys(el) 70 | # end 71 | # def enable_input(el = nil) 72 | # # _listen_mouse(el) 73 | # _listen_keys(el) 74 | # end 75 | 76 | # And this is for the other/alternative method where the screen 77 | # first gets the keys, then potentially passes onto children 78 | # elements. 79 | def _listen_keys(el : Widget? = nil) 80 | if el && !@keyable.includes? el 81 | el.keyable = true 82 | @keyable.push el 83 | end 84 | 85 | return if @_listening_keys 86 | @_listening_keys = true 87 | 88 | # Note: The event emissions used to be reversed: 89 | # element + screen 90 | # They are now: 91 | # screen, element and el's parents until one #accepts it. 92 | # After the first keypress emitted, the handler 93 | # checks to make sure grab_keys, propagate_keys, and focused 94 | # weren't changed, and handles those situations appropriately. 95 | 96 | on(Crysterm::Event::KeyPress) do |e| 97 | # If we're not propagate keys and the key is not on always-propagate 98 | # list, we're done. 99 | if !@propagate_keys && !@always_propagate.includes?(e.key) 100 | next 101 | end 102 | 103 | # XXX the role of `grab_keys` is a little unclear. It makes sense that 104 | # enabling it would not emit/announce keys. It could be thought of like: 105 | # - propagate_keys=false -> stops key handling 106 | # - grab_keys=true -> does handle keys, but grabs them, doesn't pass on 107 | # But this doesn't seem to be the case because, grab_keys can be true, 108 | # but if it is, there is no code that processes it in any way internally. 109 | # Maybe the code/hook is missing where all keys are passed onto the widget 110 | # grab them? 111 | 112 | grab_keys = @grab_keys 113 | # If key grab is not active, or key is whitelisted, announce it. 114 | # NOTE See implementation of emit_key --> it emits both the generic key 115 | # press event as well as a specific key event, if one exists. 116 | if !grab_keys || @always_propagate.includes?(e.key) 117 | # XXX 118 | # emit_key self, e 119 | end 120 | 121 | # If something changed from the screen key handler, stop. 122 | if (@grab_keys != grab_keys) || !@propagate_keys || e.accepted? 123 | next 124 | end 125 | 126 | # Here we pass the key press onto the focused widget. Then 127 | # we keep passing it through the parent tree until someone 128 | # `#accept`s the key. If it reaches the toplevel Widget 129 | # and it isn't handled, we drop/ignore it. 130 | # 131 | # XXX But look at this. Unless the key is processed by screen, it gets 132 | # passed to widget in focus and from there to its parents. How can a widget 133 | # on a screen, which is not in focus, 134 | focused.try do |el2| 135 | while el2 && el2.is_a? Widget 136 | if el2.keyable? 137 | emit_key el2, e 138 | end 139 | 140 | if e.accepted? 141 | break 142 | end 143 | 144 | el2 = el2.parent 145 | end 146 | end 147 | end 148 | end 149 | 150 | # Emits a Event::KeyPress as usual and also emits an event for 151 | # the individual key, if any. 152 | # 153 | # This allows listeners to not only listen for a generic 154 | # `Event::KeyPress` and then check for `#key`, but they can 155 | # directly listen for e.g. `Event::KeyPress::CtrlP`. 156 | @[AlwaysInline] 157 | def emit_key(el, e : Event) 158 | if el.handlers(e.class).any? 159 | el.emit e 160 | end 161 | if e.key 162 | Crysterm::Event::KeyPress::KEYS[e.key]?.try do |keycls| 163 | if el.handlers(keycls).any? 164 | el.emit keycls.new e.char, e.key, e.sequence 165 | end 166 | end 167 | end 168 | end 169 | 170 | # # Unused 171 | # def key(key, handler) 172 | # end 173 | 174 | # def once_key(key, handler) 175 | # end 176 | 177 | # def remove_key(key, wrapper) 178 | # end 179 | end 180 | end 181 | --------------------------------------------------------------------------------