diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..95c370a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb0fb1e..cab44a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ["2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", ruby-head, jruby-9.2, jruby-9.3, jruby-head] + ruby: ["2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", ruby-head, jruby-9.2, jruby-9.3, jruby-head] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index d87d4be..7cec3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ spec/reports test/tmp test/version_tmp tmp +remedy.log diff --git a/.vscode/remedy.code-workspace b/.vscode/remedy.code-workspace new file mode 100644 index 0000000..bab1b7f --- /dev/null +++ b/.vscode/remedy.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": ".." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..8ca5ab7 --- /dev/null +++ b/.yardopts @@ -0,0 +1,3 @@ +--no-private +--markup markdown +lib/**/*.rb diff --git a/Gemfile b/Gemfile index 1a0a42d..92eed56 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,11 @@ source 'https://rubygems.org' gemspec unless ENV['CI'] then - gem 'pry' - gem 'pry-doc' - gem 'pry-theme' + gem "pry" + gem "pry-doc" + gem "pry-coolline" + gem "pry-theme" + gem "logsaber" + gem "yard" + gem "webrick" end diff --git a/README.markdown b/README.markdown index 3eea5c6..725517c 100644 --- a/README.markdown +++ b/README.markdown @@ -45,7 +45,7 @@ There are objects for input as well as output, including low level console keyst The `Interaction` object wraps raw keyboard reads and streamlines some aspects of accepting keyboard input. -For instance to get a keypress from the terminal and display it: +For instance to get a keypress from the terminal and display what it is called: ```ruby include Remedy @@ -75,7 +75,7 @@ Content in `Remedy::Partial`s will be truncated as needed to accommodate the hea ```ruby include Remedy title = Partial.new - title << "Someone Said These Were Good" + title << "Two Longer Jokes" jokes = Content.new jokes << %q{1. A woman gets on a bus with her baby. The bus driver says: 'Ugh, that's the ugliest baby I've ever seen!' The woman walks to the rear of the bus and sits down, fuming. She says to a man next to her: 'The driver just insulted me!' The man says: 'You go up there and tell him off. Go on, I'll hold your monkey for you.'} @@ -85,7 +85,9 @@ Content in `Remedy::Partial`s will be truncated as needed to accommodate the hea disclaimer << "According to a survey they were funny. I didn't make them." screen = Viewport.new - screen.draw jokes, Size.new(0,0), title, disclaimer + screen.header = title + screen.footer = disclaimer + screen.draw jokes ``` ### Console @@ -101,8 +103,8 @@ The most interesting function in my opinion is the callback that gets triggered Console.set_console_resized_hook! do |size| notice = Partial.new - notice << "You just resized your screen!\n\nNew size:" - notice << size + notice << "You just resized your screen!" + notice << "New size: #{size}" screen.draw notice end ``` diff --git a/examples/from_readme/Gemfile b/examples/from_readme/Gemfile index 07849d7..9db9539 100644 --- a/examples/from_readme/Gemfile +++ b/examples/from_readme/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "remedy", path: "../.." \ No newline at end of file +gem "remedy", path: "../.." diff --git a/examples/from_readme/readme.rb b/examples/from_readme/readme.rb index 70aca02..d59b649 100644 --- a/examples/from_readme/readme.rb +++ b/examples/from_readme/readme.rb @@ -4,12 +4,22 @@ include Remedy +def cleanup + Console.cooked! + ANSI.cursor.show! +end + +trap "SIGINT" do + cleanup + exit 130 +end + screen = Viewport.new Console.set_console_resized_hook! do |size| notice = Partial.new - notice << "You just resized your screen!\n\nNew size:" - notice << size + notice << "You just resized your screen!" + notice << "New size: #{size}" screen.draw notice end @@ -25,7 +35,7 @@ user_input.get_key title = Partial.new -title << "Someone Said These Were Good" +title << "Two Longer Jokes" jokes = Partial.new jokes << %q{1. A woman gets on a bus with her baby. The bus driver says: 'Ugh, that's the ugliest baby I've ever seen!' The woman walks to the rear of the bus and sits down, fuming. She says to a man next to her: 'The driver just insulted me!' The man says: 'You go up there and tell him off. Go on, I'll hold your monkey for you.'} @@ -34,10 +44,15 @@ disclaimer = Partial.new disclaimer << "According to a survey they were funny. I didn't make them." -screen.draw jokes, Size.new(0,0), title, disclaimer +screen.header = title +screen.footer = disclaimer +screen.draw jokes user_input.get_key +screen.header = Partial.new %w{Monitoring\ Keypresses...} +screen.footer = Partial.new + ANSI.cursor.next_line! keys = Partial.new loop_demo = Interaction.new "press q to exit, or any other key to display that key's name\n" @@ -48,5 +63,4 @@ break if key == ?q end -Console.cooked! -ANSI.cursor.show! +cleanup diff --git a/examples/menu/Gemfile b/examples/menu/Gemfile index 07849d7..9db9539 100644 --- a/examples/menu/Gemfile +++ b/examples/menu/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "remedy", path: "../.." \ No newline at end of file +gem "remedy", path: "../.." diff --git a/examples/menu/menu.rb b/examples/menu/menu.rb index 8412691..a64c461 100644 --- a/examples/menu/menu.rb +++ b/examples/menu/menu.rb @@ -7,6 +7,7 @@ class Menu def initialize @viewport = Viewport.new + @pane = Pane.new viewport: @viewport end # will do basic setup and then loop over user input @@ -34,7 +35,10 @@ def listen # this tells the Viewport to draw to the screen def draw - @viewport.draw content, Size.zero, header, footer + @viewport.content = content + @viewport.header = header + @viewport.footer = footer + @viewport.draw end # this is the body of our menu, it will be squished if the terminal is too small diff --git a/examples/new_example/Gemfile b/examples/new_example/Gemfile new file mode 100644 index 0000000..41b1586 --- /dev/null +++ b/examples/new_example/Gemfile @@ -0,0 +1,8 @@ +source "https://rubygems.org" + +gem "remedy", path: "../.." +gem "pry" +gem "pry-doc" +gem "pry-coolline" +gem "pry-theme" +gem "logsaber" diff --git a/examples/new_example/new_example.rb b/examples/new_example/new_example.rb new file mode 100644 index 0000000..7a65700 --- /dev/null +++ b/examples/new_example/new_example.rb @@ -0,0 +1,145 @@ +require "remedy" +require "remedy/screen" +require "remedy/frame" + +class Example + include Remedy + + def self.cleanup + ANSI.screen.safe_reset! + Console.cooked! + ANSI.cursor.show! + end + + def setup + trap "SIGINT" do + Example.cleanup + warn "Caught control-c interupt!" + exit 130 + end + end + + def run + screen = Screen.new name: "Main Screen" + screen.frames << lsidebar + screen.frames << rsidebar + screen.frames << content + screen.frames << window + screen.frames << modal + + screen.draw + + Interaction.new.get_key + ensure + self.class.cleanup + end + + def lsidebar + f = Frame.new name: "lsidebar" + f.horigin = :left + f.vorigin = :center + f.halign = :left + f.valign = :center + f.size = Tuple 0, 0.25 + f.fill = "=" + f + end + + def rsidebar + f = Frame.new name: "rsidebar" + f.horigin = :right + f.vorigin = :center + f.halign = :left + f.valign = :center + f.size = Tuple 0, 0.25 + f.fill = "+" + f + end + + def content + f = Frame.new name: "content" + f.horigin = :center + f.vorigin = :center + f.halign = :left + f.valign = :top + f.size = Tuple 0, 0.5 + f.fill = ":" + f.arrangement = :columnar + f << l_col + f << r_col + f + end + + def l_col + f = Frame.new name: "l_col" + f << "foo" + f.halign = :left + f.valign = :top + f.fill = "-" + f.size = Tuple 5, 5 + f + end + + def r_col + f = Frame.new name: "r_col" + f << "bar" + f.halign = :right + f.valign = :bottom + f.fill = "|" + f.size = Tuple 5, 5 + f + end + + def modal + f = Frame.new name: "modal" + f.horigin = :center + f.vorigin = :center + f.halign = :center + f.valign = :center + f.size = Tuple 3, 15 + f.fill = "#" + + msg = "hello, world!" + f << msg + f + end + + def window + f = Frame.new name: "window" + f.arrangement = :arbitrary + f.offset = Tuple 5, 5 + f.size = Tuple 10, 20 + f.fill = "'" + f << titlebar + f << statusbar + f + end + + def titlebar + f = Frame.new name: "titlebar" + f.vorigin = :top + f.halign = :center + # FIXME: zero sizes don't yet work for nested frames + #f.size = Tuple 1, 0 + f.size = Tuple 1, 20 + f.fill = "━" + f << "┫ title ┣" + f + end + + def statusbar + f = Frame.new name: "statusbar" + f.vorigin = :bottom + f.horigin = :left + f.halign = :center + f.depth = 3 + # FIXME: zero sizes don't yet work for nested frames + #f.size = Tuple 1, 0 + f.size = Tuple 1, 20 + f.fill = "┄" + f << Time.now.to_s + f + end +end + +Example.new.run diff --git a/lib/remedy/align.rb b/lib/remedy/align.rb new file mode 100644 index 0000000..f5874f7 --- /dev/null +++ b/lib/remedy/align.rb @@ -0,0 +1,86 @@ +module Remedy + module Align + + module_function + + # Center content with a single line by padding out the left and right sides. + # + # @param content [String] the line to be centered + # @param size [Remedy::Tuple] a Tuple with a width - it controls the centering + # @param fill [String] the string to fill the space with + def h_center_p content, size, fill: " " + head, tail = middle_spacing content.length, size.width + (fill * head) + content + (fill * tail) + end + + # Left align by padding the right side to fill out the total width. + def left_p content, size, fill: " " + space = size.width - content.length + return content if space < 0 + content + (fill * space) + end + + # Right align by padding the left side to fill out the total width. + def right_p content, size, fill: " " + space = size.width - content.length + return content if space < 0 + (fill * space) + content + end + + # Center content in the middle of a buffer, both vertically and horizontally. + # + # @param content [Remedy::Partial] any object that responds to `height`, `width`, and a `to_a` that returns an array of strings + # @param buffer [Remedy::Screenbuffer] a screenbuffer to write to + # @return [content] whatever was passed in as the `content` param will be returned + def hv_center content, buffer + voffset = mido content.height, buffer.size.height + hoffset = mido content.width, buffer.size.width + buffer[voffset,hoffset] = content + end + + # Middle offset. + # + # Given the actual space something takes up, + # determine what the offset to get it centered in the available space. + # + # @param actual [Numeric] the space already taken + # @param available [Numeric] the available space + # @return [Integer] the offset from the end of the availabe space to center the actual content + def mido actual, available + return 0 unless actual < available + + offset = ((available - actual) / 2.0).floor + end + + # Bottom offset. + # + # Given the actual space something takes up, + # determine what the offset to get it at the far edge in the available space. + # + # @param actual [Numeric] the space already taken + # @param available [Numeric] the available space + # @return [Integer] the offset from the end of the availabe space to place the actual content at the end + def boto actual, available + return available unless actual < available + + offset = available - actual + end + + # Given the actual space something takes up, + # determine what the offset to get it centered in the available space, + # including the trailing space remaining. + # + # @param actual [Numeric] the space already taken + # @param available [Numeric] the available space + # @return [Array] two element Array with the remaining space on each side + # (since contents may be shifted to one side or the other by 1 space) + def middle_spacing actual, available + return [0,0] unless actual < available + + head = mido actual, available + tail = available - (head + actual) + + [head, tail] + end + end +end diff --git a/lib/remedy/console.rb b/lib/remedy/console.rb index 25cae6a..335d18f 100644 --- a/lib/remedy/console.rb +++ b/lib/remedy/console.rb @@ -23,6 +23,14 @@ def output @output ||= $stdout end + def input= new_input + @input = new_input + end + + def output= new_output + @output = new_output + end + def raw raw! result = yield @@ -52,14 +60,20 @@ def rows alias_method :height, :rows def size + return @size_override if @size_override + str = [0, 0, 0, 0].pack('SSSS') - if input.ioctl(TIOCGWINSZ, str) >= 0 then - Size.new str.unpack('SSSS').first 2 + if input.respond_to?(:ioctl) && input.ioctl(TIOCGWINSZ, str) >= 0 then + Tuple.new str.unpack('SSSS').first 2 else raise UnknownConsoleSize, "Unable to get console size" end end + def size_override= new_size + @size_override = new_size + end + def interactive? input.isatty end diff --git a/lib/remedy/frame.rb b/lib/remedy/frame.rb new file mode 100644 index 0000000..2a4fca0 --- /dev/null +++ b/lib/remedy/frame.rb @@ -0,0 +1,422 @@ +require "remedy/tuple" +require "remedy/align" +require "remedy/screenbuffer" +require "remedy/text_util" +require "remedy" + +module Remedy + # Frames contain Panes and Panes contain Partials + # Frames can be nested within other Frames or Panes + class Frame + def initialize name: self.object_id, content: nil, parent: nil + @name = name + + # vorigin is where the frame will be attached vertically + # :top, :bottom, :center + @vorigin = :top + + # horigin is where the frame will be attached horizontally + # :left, :right, :center + @horigin = :left + + # offset is what the offset from that origin the frame should be placed + @offset = Tuple.zero + + # horizontal alignment of contents + # :left, :right, :center + @halign = :left + + # vertical alignment of contents + # :top, :bottom, :center + @valign = :top + + # depth is the z index or layer, higher numbers cover lower numbers + # if two frames have the same layer but would overlap, then the one added most recently should come out on top + @depth = 0 + + # arrangement, if this frame contains multiple content items, then they will be arranged according to this + # :stacked, :columnar, :arbitrary, :tabbed(?) + @arrangement = :stacked + + # spacing as to how multiple content items should be spaced in an arrangement + # has no effect when `arrangement = :arbitrary` + # :none, :evenly + @spacing = :none + + # the maximum size that this frame wants to be, the actual size may be smaller + # :none - frame has no size, contents are not aligned + # :auto - frame conforms to the size of its largest content and aligns to it + # :fill - the frame tries to fill as much space as possible + # Tuple - specify a Tuple to constrain it to a specific size + # Tuple.zero is same as :none + @size = :none + + # this size is used when max_size is set to :fill + # typically set by a screen object after resize + @available_size = Tuple.zero + + # empty list of contents + @contents = Array.new + if content then + self << content + end + + # background fill + @fill = " " + + # newline character + @nl = ?\n + + @parent = parent + end + + attr_accessor :vorigin, :horigin, :depth + attr_accessor :name, :size, :available_size + attr_accessor :nl, :fill, :halign, :valign + attr_reader :contents + attr_accessor :parent + + # Determines how contents are arranged when compiling. + # + # Possible values are: + # + # - `:stacked` + # - `:columnar` + # - `:arbitrary` + # + # @return [Symbol] one of the preset arrangements + attr_accessor :arrangement + + # Sets the offset from the origin point. + # @note Positive offsets always move the frame right and down, + # so negative values are more useful when `horigin = :right` and/or `vorigin = :bottom`. + # @return [Remedy::Tuple] the vertical and horizontal offset to apply + attr_accessor :offset + + # The computed size is the actual size of the Frame after taking into account all of the different factors. + # @note This value is invalid until after the contents have been compiled. + # @return [Remedy::Tuple,nil] the size Tuple or `nil` if the Frame has not yet been compiled + attr_reader :computed_size + + # The cached Frame buffer from the last compilation. + # @note This value is invalid until after the contents have been compiled. + # @return [Remedy::Screenbuffer,nil] the buffer or `nil` if the Frame has not yet been compiled + attr_accessor :buffer + + def << new_content + case new_content + when String, Array + conformed_content = TextUtil.nlclean(new_content) + else + conformed_content = new_content + end + @contents << conformed_content + end + + def [] *index + to_a[*index] + end + + def reset! + @contents.clear + end + + def to_a + compile_contents + end + + def to_s + compile_contents.join nl + end + + def to_str + to_s + end + + def to_ansi + compile_contents.join ANSI.cursor.next_line + end + + def content_size + sizeof arrange_contents + end + + def length + if size == :none then + content_size.width + elsif computed_size then + computed_size.width + else + compile_contents + computed_size.width + end + end + + def compile_contents + # TODO: insert dirty check and then skip the rest of this if no changes detected, + # also a param which overrides this + + c = arrange_contents + + return c if size == :none + + csize = sizeof c + @computed_size = compute_actual_size csize + + if buffer then + buffer.reset! + buffer.resize computed_size + else + @buffer = Screenbuffer.new computed_size, fill: fill, nl: nl, parent: self, name: "compile_contents" + end + + hoffset = compute_horizontal_offset csize, computed_size + voffset = compute_vertical_offset csize, computed_size + + align_contents! c, csize + + buffer[voffset,hoffset] = c + buffer.to_a + end + + # Determine what the actual output size would be based on the size option and contents. + # + # Most of the time the output size can be determined statically based on the available + # size information and the one parameter. + # + # In practice `:none` and `:auto` output the same maximum height and width - despite rendering differences. + # Technically, `:none` will always result in `content_size`, + # while `:auto` could in theory be further modified by the alignment and later processing. + # + # @parram arranged_size [Remedy::Tuple] an externally determined size after preprocessing + # @return [Remedy::Tuple] output size in rows/height and columns/width + def compute_actual_size arranged_size = content_size + case size + when :none + # generally identical to `arranged_size` + # if needed, using that here could save processing + content_size + when :fill + available_size + when :auto + arranged_size + when Tuple + actual_size = size.dup + + if size.height == 0 then + actual_size[0] = available_size.height + elsif size.height < 1 then + actual_size[0] = (available_size.height * size.height).floor + end + + if size.width == 0 then + actual_size[1] = available_size.width + elsif size.width < 1 then + actual_size[1] = (available_size.width * size.width).floor + end + + actual_size + else + raise "Unknown max_size:#{size}" + end + end + alias_method :actual_size, :compute_actual_size + + def compute_horizontal_offset original_size, actual_size + case halign + when :left + hoffset = 0 + when :right + hoffset = actual_size.width - original_size.width + when :center + hoffset = Align.mido original_size.width, actual_size.width + else + raise "Unknown halign:#{halign}" + end + + hoffset + end + + def compute_vertical_offset original_size, actual_size + case valign + when :top + voffset = 0 + when :bottom + voffset = actual_size.height - original_size.height + when :center + voffset = Align.mido original_size.height, actual_size.height + else + raise "Unknown valign:#{valign}" + end + + voffset + end + + def arrange_contents + content_to_arrange = depth_sort + + case arrangement + when :stacked + arrange_stacked content_to_arrange + when :columnar + arrange_columnar content_to_arrange + when :arbitrary + arrange_arbitrary content_to_arrange + else + raise "unknown arrangement: #{arrangement}" + end + end + + def arrange_stacked content_to_arrange + # TODO: insert padding? + content_to_arrange.map do |content| + content.available_size = available_size if content.respond_to? :available_size + content.to_a + end.flatten + end + + def arrange_arbitrary content_to_arrange + if size.is_a? Tuple then + buffer_size = size + else + buffer_size = available_size + expand_buffer = available_size.zero? + end + arrange_buffer = Screenbuffer.new buffer_size, fit: expand_buffer, fill: fill, parent: self, name: "arrange_arbitrary" + + result = content_to_arrange.each do |frame| + # special case handling of plain Strings and Arrays + unless frame.is_a? Frame then + arrange_buffer[Tuple.zero] = frame + buffer_size = arrange_buffer.size + next + end + + # FIXME: what happens when the buffer size is zero? the buffer will grow, right? + frame.available_size = buffer_size + content = frame.compile_contents + fsize = frame.computed_size || frame.content_size + + case frame.vorigin + when :top + voffset = 0 + when :center + voffset = Align.mido fsize.height, buffer_size.height + when :bottom + voffset = Align.boto fsize.height, buffer_size.height + else + raise "Unknown vorigin:#{frame.vorigin}" + end + + # this line works around an edge case where only :top vorigins would + # be rendered when available_size was zero and the frame size was :none + voffset = 0 if frame.vorigin != :top && buffer_size.height == 0 && voffset < 0 + + case frame.horigin + when :left + hoffset = 0 + when :center + hoffset = Align.mido fsize.width, buffer_size.width + when :right + hoffset = Align.boto fsize.width, buffer_size.width + else + raise "Unknown horigin:#{frame.horigin}" + end + + voffset += frame.offset.height + hoffset += frame.offset.width + + offset = Tuple voffset, hoffset + + arrange_buffer[offset] = content + buffer_size = arrange_buffer.size + end + + arrange_buffer.to_a + end + + def arrange_columnar content_to_arrange + content_list = content_to_arrange + rows = maxsizeof(content_list).row + result = Array.new + + content_sizes = [0] # the first column starts with zero + + content_list.each_with_object(content_sizes).with_index do |(content, cs), i| + content.available_size = available_size if content.respond_to? :available_size + cs << sizeof(content).width + cs[i] + end + + rows.times do |row| + arranged_line = "" + + content_list.each.with_index do |content, index| + line = content[row] + if line then + padding = fill * [content_sizes[index] - arranged_line.length, 0].max + arranged_line << padding + arranged_line << line + end + end + result << arranged_line + end + result + end + + def align_contents! content_to_align, original_size + case halign + when :left + # noop + when :right + content_to_align.map! do |line| + line = Align.right_p line, original_size, fill: fill + end + when :center + content_to_align.map! do |line| + line = Align.h_center_p line, original_size, fill: fill + end + else + raise "Unknown halign:#{halign}" + end + content_to_align + end + + protected + + def maxsizeof content_list + content_sizes = content_list.map do |content| + sizeof content + end + + height = content_sizes.map(&:height).max || 0 + width = content_sizes.map(&:width).max || 0 + Tuple height, width + end + + def sizeof content + lines = TextUtil.nlclean(content, self).flatten(1) + + height = lines.length + width = lines.map(&:length).max || 0 + Tuple height, width + end + + def depth_sort content_list = contents + content_list.sort do |a,b| + a.parent = self if a.respond_to? :parent= + b.parent = self if b.respond_to? :parent= + depthof(a) <=> depthof(b) + end + end + + def depthof content + case content + when Frame + content.depth + else + 0 + end + end + + end # Frame class +end # Remedy module diff --git a/lib/remedy/pane.rb b/lib/remedy/pane.rb new file mode 100644 index 0000000..814f463 --- /dev/null +++ b/lib/remedy/pane.rb @@ -0,0 +1,83 @@ +require 'remedy/view' +require 'remedy/tuple' +require 'remedy/console' +require 'remedy/ansi' + +module Remedy + + # By default a Pane will fill all available area + # If a Pane is constrained to a specific size, then it will only take up that space + # Any content that wouldn't fit to the constrained size will be truncated + class Pane + def initialize size: Tuple.zero, content: Partial.new, viewport: Viewport.new + @size = size + @content = content + @viewport = viewport + end + + def draw content, scroll = Tuple.zero + range = range_find @content, scroll, @viewport.size + + viewable_content = @content.excerpt *range + + @viewport.draw viewable_content + end + + # This is the target size of this pane, but may still be truncated if there is not enough room + def size + Tuple(height, width) + end + + def height + if @size.height > 0 then + @size.height + else + content_height + end + end + + def width + if @size.width > 0 then + @size.width + else + content_width + end + end + + protected + + def range_find partial, scroll, available_heightwidth + avail_height, avail_width = available_heightwidth + partial_height, partial_width = partial.size + + center_row, center_col = scroll + + row_range = visible_range center_row, partial_height, avail_height + col_range = visible_range center_col, partial_width, avail_width + + [row_range, col_range] + end + + def visible_range offset, actual, available + # if the actual content can fit into the available space, then we're done + return (0...actual) if actual <= available + + # otherwise start looking at the scrolling offset, if any + + # clamp the offset within the possible range of the actual content + if offset < 0 then + range_start = 0 + elsif offset > actual then + range_start = actual + else + range_start = offset + end + + # determine the subset of content that can be displayed + range_end = range_start + (available - offset) + + (range_start...range_end) + end + + end +end diff --git a/lib/remedy/partial.rb b/lib/remedy/partial.rb index 5e6e0ff..7bd6416 100644 --- a/lib/remedy/partial.rb +++ b/lib/remedy/partial.rb @@ -33,11 +33,11 @@ def height alias_method :length, :height def width - lines.map{|line| line.length }.max + lines.map{|line| line.length }.max || 0 end def size - Size.new height, width + Tuple.new height, width end def to_a diff --git a/lib/remedy/screen.rb b/lib/remedy/screen.rb new file mode 100644 index 0000000..1ecf823 --- /dev/null +++ b/lib/remedy/screen.rb @@ -0,0 +1,96 @@ +require "remedy/tuple" +require "remedy/console" +require "remedy/ansi" +require "remedy/screenbuffer" +require "remedy/align" + +module Remedy + class Screen + # Create a new Screen object. + # + # Only one Screen object should be in use at a time + # because they talk directly to the raw terminal. + # But feel free to have multiple available and swap between them, + # Might be good for having multiple workspaces. + # + # @param auto_resize [Boolean] can be disabled if you are setting up your own console resize hook + # @see #resize + # @see Console.set_console_resized_hook! + def initialize auto_resize: true, auto_redraw: true, name: object_id + @mainframe = Frame.new name: "screen#init", parent: self + mainframe.fill = "." + mainframe.size = :fill + mainframe.arrangement = :arbitrary + + Console.set_console_resized_hook! do |new_size| + resize new_size, redraw: auto_redraw + end if auto_resize + end + attr_accessor :mainframe, :name + + # Draw the buffer to the console using raw output. + # @param override [Remedy::Frame,String] temporarily replace the contents with this instead (until the next redraw!) + # @return [void] + def draw override = nil + mainframe.available_size = Console.size + if override then + f = Frame.new name: "screen#draw/override", parent: self, content: override + f.size = :fill + f.fill = "." + f.available_size = mainframe.available_size + f.halign = :center + f.valign = :center + frame = f + else + refresh + frame = mainframe + end + ANSI.screen.safe_reset! + Console.output << frame.to_ansi + end + + def frames + mainframe.contents + end + + # This sets the new screen size and rebuilds the buffer before redrawing it. + # + # Called automatically unless `auto_resize` was set to `false`, + # or if the console resized hook was changed to something else. + # + # If setting up your own `Console.set_console_resized_hook!` callback + # then you can use this as a starting point: + # + # ```ruby + # Console.set_console_resized_hook! do |new_size| + # my_screen.resize new_size + # end + # ``` + # + # @param new_size [Remedy::Tuple] the new size of the terminal + # @return [void] + def resize new_size, redraw: true + mainframe.available_size = new_size + draw if redraw + end + + def to_a + refresh + mainframe.to_a + end + + def to_s + refresh + mainframe.to_s + end + + def to_ansi + refresh + mainframe.to_ansi + end + + def refresh + mainframe.compile_contents + end + end +end diff --git a/lib/remedy/screenbuffer.rb b/lib/remedy/screenbuffer.rb new file mode 100644 index 0000000..d5f3911 --- /dev/null +++ b/lib/remedy/screenbuffer.rb @@ -0,0 +1,228 @@ +require "remedy/tuple" +require "remedy/ansi" +require "remedy/text_util" + +module Remedy + # A screenbuffer is an in-memory representation of the terminal display. + # Even most modern terminals do not allow direct access to the character display array. + # So we create our own, like a DOM, to do our work on before rendering it to the screen. + class Screenbuffer + + # Create a new screenbuffer. + # + # @param size [Remedy::Tuple] the number of rows and columns to allocate + # @param fill [String] a character to pre-fill the buffer with + # @param nl [String] the sequence used to separate lines when converted to a string + # @param ellipsis [String] the character used to indicate truncated lines, + # if set to `nil` then content will extend to the edge of the screen + # @param charwidth [Numeric] in case we are able to support multiple character widths in the future + def initialize size, fill: " ", nl: ?\n, ellipsis: "…", charwidth: 1, fit: false, parent: nil, name: object_id + raise ArgumentError, "size cannot be `nil'!" if size.nil? + @charwidth = charwidth + @size = size + @fill = fill[0, charwidth] + @nl = nl + @ellipsis = ellipsis + @buf = new_buf + @fit = fit + @parent = parent + @name = name + end + attr_accessor :fill, :nl, :ellipsis, :charwidth, :fit, :parent, :name + + # Get the contents of the buffer at a given coordinate. + # + # @overload [] coords + # @param coords [Remedy::Tuple] the coordinates to read from + # + # @overload [] row, col + # @param row [Numeric] the row to read from, 0 indexed + # @param col [Numeric] the column to read from, 0 indexed + # + # @todo get more than single characters + def [] *params + coords = Tuple params.flatten + buf[coords.row][coords.col] + end + + # Replace the contents of the buffer at a given coordinate. + # + # @overload []= coords, value + # @param coords [Remedy::Tuple] the coordinates to begin replacing from + # @param value [String, Enumerable] the content to place into the screenbuffer + # Usage with Tuple coordinates and line array: + # ```ruby + # coords = Tuple 3, 4 + # lines = ["foo\nbar", "baz"] + # screenbuffer[coords] = lines + # ``` + # @overload []= row, col, value + # @param row [Numeric] the row to start replacing from, 0 indexed + # @param col [Numeric] the column to start replacing from, 0 indexed + # @param value [String, Enumerable] the content to place into the screenbuffer + # + # Usage with scalar coordinates and simple string: + # ```ruby + # row = 1 + # col = 2 + # screenbuffer[row, col] = "foo" + # ``` + def []= *params + value = params.pop + coords = Tuple params.flatten + + replace_perline coords, value + end + + # @return [Array] the raw screenbuffer array + def buf + @buf ||= new_buf + end + + # Replace the contents of the internal buffer. + # + # Primarily useful for testing. + # Could also be used for double/triple buffering implementation. + # + # @param override_buf [Array] the new replacement buffer contents + def buf= override_buf + self.size = compute_actual_size override_buf + @buf = override_buf + end + + # @return [Remedy::Tuple] the size of the buffer in rows and columns + def size + @size.dup + end + + # @return [Array] the contents of the buffer as an array of strings + def to_a + buf[0,size.height].map do |line| + line[0,size.width] + end + end + + # Convert screenbuffer to single String. + # Concatenates the contents of the buffer with the `nl` attribute. + def to_s + to_a.join nl + end + + # Convert screenbuffer to single string for output to a display using ANSI line motions. + # Standard newlines at screen edges cause many terminals to display extraneous empty lines. + def to_ansi + to_a.join ANSI.cursor.next_line + end + + # Reset contents of buffer to the empty state using the @fill character. + def reset! + @buf = new_buf + end + + # Set a new size for the screenbuffer. + # @todo The buffer is not shrank or otherwise truncated when the size changes. + # + # @param new_size [Remedy::Tuple] the new size, + # as typically received from `Console.size` or + # `Console.set_console_resized_hook!` + # @raise [ArgumentError] if passed anything other than a Remedy::Tuple + def resize new_size + raise ArgumentError unless new_size.is_a? Tuple + # @size is set to the externally visible value, + # this allows us to shrink the apparent size + # without destroying the contents of the buffer + actual_size = compute_actual_size + + if new_size.height > actual_size.height then + grow_by = new_size.height - actual_size.height + grow_by.times do + buf << new_buf_line + end + end + if new_size.width > actual_size.width then + grow_by = new_size.width - actual_size.width + buf.each do |l| + # TODO: handle char width? + l << fill * grow_by + end + end + raise RangeError, "cannot have buffer width without buffer height! new_size: #{new_size}" if new_size.height < 1 and new_size.width > 0 + + @size = new_size.dup + end + alias_method :size=, :resize + + def fit_height height + grow_height height - @size.height + end + + def fit_width width + grow_width width - @size.width + end + + def grow_height additional_rows + grow Tuple(additional_rows, 0) if additional_rows > 0 + end + + def grow_width additional_cols + grow Tuple(0, additional_cols) if additional_cols > 0 + end + + def grow amount + resize @size + amount + end + + def compute_actual_size array2d = buf + Tuple array2d.length, (array2d.map{|l|l.length}.max || 0) + end + + private + + def new_buf + Array.new(size.height) do + new_buf_line + end + end + + def new_buf_line + fill * size.width * charwidth + end + + def replace_perline coords, value + # Array() checks for `.to_a` on whatever is passed to it + lines = Array(value).map do |line| + TextUtil.nlclean line + end.flatten + + lines.each.with_index do |line, index| + new_coords = coords + Tuple(index,0) + if new_coords.height >= size.height then + if fit then + fit_height new_coords.height + 1 + size.height = new_coords.height + else + return + end + end + + replace_inline new_coords, line + end + + self + end + + def replace_inline coords, value + if fit then + fit_width value.length + truncated_value = value # not truncated + else + truncated_value = value[0,size.width - coords.col] + truncated_value[-1] = ellipsis[0,charwidth] if ellipsis && truncated_value.length < value.length + end + + raise RangeError, "Negative range #{coords} extends beyond boundary #{size} by #{size.aogd coords.abs} for #{"#{parent.name}/" if parent && parent.respond_to?(:name)}#{name}" if (-coords.row) > size.height + + buf[coords.row][coords.col,truncated_value.length] = truncated_value + end + end +end diff --git a/lib/remedy/size.rb b/lib/remedy/size.rb deleted file mode 100644 index fc2fc32..0000000 --- a/lib/remedy/size.rb +++ /dev/null @@ -1,109 +0,0 @@ -module Remedy - class Size - def initialize *new_dimensions - new_dimensions.flatten! - if new_dimensions.first.is_a? Range then - new_dimensions.map! do |range| - range.to_a.length - end - end - @dimensions = new_dimensions - end - attr_accessor :dimensions - - def self.zero - self.new([0,0]) - end - - def - other_size - if other_size.respond_to? :length then - self.class.new subtract(other_size) - else - self.class.new deduct(other_size) - end - end - - def / value - dimensions.map do |dimension| - dimension / value - end - end - - def << value - self.dimensions << value - end - - - def fits_into? size_to_fit_into - other_size = Size(size_to_fit_into) - length.times.each do |index| - return false if self[index] > other_size[index] - end - true - end - - def rows - dimensions[0] - end - alias_method :height, :rows - - def cols - dimensions[1] - end - alias_method :columns, :cols - alias_method :width, :cols - - def [] index - dimensions[index] - end - - def length - dimensions.length - end - - def to_a - dimensions.dup - end - - def to_ary - dimensions - end - - def to_s - "(#{dimensions.join('x')})" - end - - def inspect - "#<#{self.class}:#{to_s}>" - end - - protected - - def deduct amount - dimensions.map do |dimension| - dimension - amount - end - end - - def subtract other_size - sizesame? other_size - - length.times.inject Size.new do |difference, index| - difference << self[index] - other_size[index] - end - end - - def sizesame? other_size - raise "Different numbers of dimensions!" unless length == other_size.length - end - end -end - -def Size *sizeable - sizeable.flatten! - if sizeable.first.is_a? Remedy::Size then - sizeable - else - Remedy::Size.new sizeable - end -end diff --git a/lib/remedy/text_util.rb b/lib/remedy/text_util.rb new file mode 100644 index 0000000..6023c75 --- /dev/null +++ b/lib/remedy/text_util.rb @@ -0,0 +1,24 @@ +module Remedy + module TextUtil + module_function + + def nlclean content, context = nil + case content + when String + nlsplit(content) + when Array + content.map do |l| + nlclean l + end + else + content.to_a + end + end + + def nlsplit line + raise ArgumentError, "Requires a String, got #{line.class} instead!" unless line.is_a? String + line.split(/\r\n|\n\r|\n|\r/) + end + + end +end diff --git a/lib/remedy/tuple.rb b/lib/remedy/tuple.rb new file mode 100644 index 0000000..a754737 --- /dev/null +++ b/lib/remedy/tuple.rb @@ -0,0 +1,338 @@ +module Remedy + # Used primarily to contain dimensional numeric values such as the sizes of screen areas, + # offsets in two or more dimensions, etc. + # + # Formerly known as `Remedy::Size`. Related to concepts in other projects + # called things like `Coordinate`, `Pair`, `Vector`, or similar. + class Tuple + def initialize *new_dimensions + dims = new_dimensions.flatten + if dims.first.is_a? self.class then + dims = dims.first.dimensions.dup + elsif dims.first.is_a? Range then + dims.map! do |range| + range.end + end + end + @dimensions = dims + end + attr_accessor :dimensions + + class << self + def zero + self.new(0,0) + end + + def tuplify *tupleable + klass = self + tupleable.flatten! + if tupleable.first.is_a? klass then + tupleable + else + klass.new tupleable + end + end + end + + # OPERATIONS + + def + other_tuple + if other_tuple.respond_to? :[] then + matrix_addition other_tuple + elsif other_tuple.respond_to? :+ then + scalar_addition other_tuple + end + end + + def - other_tuple + if other_tuple.respond_to? :length then + matrix_subtract other_tuple + else + scalar_subtract other_tuple + end + end + + def / value + dimensions.map do |dimension| + dimension / value + end + end + + def << value + self.dimensions << value + end + + # COMPARISON + + def == other_tuple + return false unless other_tuple.respond_to? :length + return false unless bijective? other_tuple + + self.dimensions.each.with_index do |d, i| + return false unless d == other_tuple[i] + end + + true + end + + # Three-way comparison operator AKA the "spaceship operator". + # + # Used for sorting. + # @return [-1,0,1] `-1` if `self > other_tuple`, `1` if `self < other_tuple`, `0` otherwise + def <=> other_tuple + self.aold(other_tuple).magnitude <=> self.aogd(other_tuple).magnitude + end + + # Determines if this Tuple is smaller than the other Tuple in all dimensions. + # @param other_tuple [Remedy::Tuple] + # @return [Boolean] `true` if this Tuple is the same size or smaller, `false` otherwise. + def fits_into? other_tuple + return false if self.cardinality > other_tuple.cardinality + cardinality.times.each do |index| + return false if self[index] > other_tuple[index] + end + true + end + + # Determines if the two tuples have the same number of dimensions + # uses `length` on the other object so it can be used in comparison with more types than just Tuples. + # @param other_tuple [Remedy::Tuple] + # @return [Boolean] + def bijective? other_tuple + cardinality == other_tuple.length + end + alias_method :sizesame?, :bijective? + + # Returns a Tuple where the dimensions are the absolute values of the current Tuple. + # @return [Remedy::Tuple] + def abs + self.class.new dimensions.map(&:abs) + end + + # Returns the total magnitude of all dimensions. + # @return [Numeric] + def magnitude + dimensions.map(&:abs).sum + end + + # The area of difference. + # + # Indicates the magnitude of the difference between two Tuples. + # + # @param other_tuple [Remedy::Tuple] + # @return [Remedy::Tuple] + def aod other_tuple + result = [cardinality, other_tuple.cardinality].max.times.with_object(self.class.new) do |index, area| + if self[index].nil? then + area << other_tuple[index].abs + elsif other_tuple[index].nil? then + area << self[index].abs + else + difference = other_tuple[index] - self[index] + + area << difference.abs + end + end + + self.class.tuplify result + end + + # The area of lesser difference. + # + # Indicates where the `other` Tuple is lesser and by how much. + # + # @param other_tuple [Remedy::Tuple] + # @return [Remedy::Tuple] + def aold other_tuple + result = [cardinality, other_tuple.cardinality].max.times.with_object(self.class.new) do |index, area| + if self[index].nil? then + area << 0 + elsif other_tuple[index].nil? then + area << self[index] + else + difference = self[index] - other_tuple[index] + + if difference < 0 then + area << 0 + else + area << difference + end + end + end + + self.class.tuplify result + end + + # The area of greater difference. + # + # Indicates where the `other` Tuple is greater and by how much. + # + # @param other_tuple [Remedy::Tuple] + # @return [Remedy::Tuple] + def aogd other_tuple + result = [cardinality, other_tuple.cardinality].max.times.with_object(self.class.new) do |index, area| + if self[index].nil? then + area << other_tuple[index] + elsif other_tuple[index].nil? then + area << 0 + else + difference = other_tuple[index] - self[index] + + if difference < 0 then + area << 0 + else + area << difference + end + end + end + + self.class.tuplify result + end + + # ACCESSORS + + def x + dimensions[0] + end + alias_method :height, :x + alias_method :row, :x + alias_method :first, :x + + def y + dimensions[1] + end + alias_method :width, :y + alias_method :col, :y + alias_method :second, :y + + def z + dimensions[2] + end + alias_method :depth, :z + alias_method :layer, :z + alias_method :third, :z + + def x= new_value + dimensions[0] = new_value + end + alias_method :height=, :x= + alias_method :row=, :x= + alias_method :first=, :x= + + def y= new_value + dimensions[1] = new_value + end + alias_method :width=, :y= + alias_method :col=, :y= + alias_method :second=, :y= + + def z= new_value + dimensions[2] = new_value + end + alias_method :depth=, :z= + alias_method :layer=, :z= + alias_method :third=, :z= + + def last + dimensions.last + end + + def [] index + dimensions[index] + end + + def []= index, value + dimensions[index] = value + end + + # @return [Integer] The number of dimensions in this Tuple. + def cardinality + dimensions.length + end + alias_method :length, :cardinality + + # @return [Boolean] `true` if all dimensions are zero, otherwise `false`. + def zero? + dimensions.all? do |d| + d == 0 + end + end + + # @return [Boolean] `true` if any dimension is nonzero, otherwise `false`. + def nonzero? + dimensions.any? do |d| + d != 0 + end + end + + # CONVERSIONS + + def to_a + dimensions.dup + end + + def to_ary + dimensions + end + + def to_s + "(#{dimensions.join('x')})" + end + + def inspect + "#<#{self.class}:#{to_s}>" + end + + def dup + new_tuple = super + new_tuple.dimensions = dimensions.dup + new_tuple + end + + protected + + def scalar_subtract! amount + dimensions.map do |dimension| + dimension - amount + end + end + + def scalar_subtract amount + dup.scalar_subtract! amount + end + + def matrix_subtract other_tuple + raise "Different numbers of dimensions! (#{cardinality} vs #{other_tuple.cardinality})" unless bijective? other_tuple + + result = cardinality.times.inject self.class.new do |difference, index| + difference << self[index] - other_tuple[index] + end + + self.class.tuplify result + end + + def scalar_addition! amount + dimensions.map do |dimension| + dimension + amount + end + end + + def scalar_addition amount + dup.add! amount + end + + def matrix_addition other_tuple + raise "Different numbers of dimensions! (#{cardinality} vs #{other_tuple.cardinality})" unless bijective? other_tuple + + result = cardinality.times.inject self.class.new do |sum, index| + sum << self[index] + other_tuple[index] + end + + self.class.tuplify result + end + end +end + +def Tuple *tupleable + ::Remedy::Tuple.tuplify tupleable +end diff --git a/lib/remedy/view.rb b/lib/remedy/view.rb index 1fbc971..cf36bec 100644 --- a/lib/remedy/view.rb +++ b/lib/remedy/view.rb @@ -14,6 +14,10 @@ def to_s force_recompile = false end end + def to_a + merged + end + protected def compile! diff --git a/lib/remedy/viewport.rb b/lib/remedy/viewport.rb index 4ef2832..7d1dd61 100644 --- a/lib/remedy/viewport.rb +++ b/lib/remedy/viewport.rb @@ -1,64 +1,47 @@ require 'remedy/view' -require 'remedy/size' +require 'remedy/tuple' require 'remedy/console' require 'remedy/ansi' +require 'remedy/pane' module Remedy - class Viewport - def draw content, scroll = Size.zero, header = Partial.new, footer = Partial.new - range = range_find content, scroll, available_space(header,footer) + class Viewport < Pane + def initialize content: Partial.new, header: Partial.new, footer: Partial.new + @content = content + @header = header + @footer = footer + end + attr_accessor :content, :header, :footer + + def draw override = nil + body = override || @content + range = range_find body, Tuple.zero, available_space(@header, @footer) - viewable_content = content.excerpt *range + viewable_content = body.excerpt *range - view = View.new viewable_content, header, footer + view = View.new viewable_content, @header, @footer ANSI.screen.safe_reset! Console.output << view end - def range_find partial, scroll, available_heightwidth - avail_height, avail_width = available_heightwidth - partial_height, partial_width = partial.size - - center_row, center_col = scroll + def size + Console.size + end - row_range = get_range center_row, partial_height, avail_height - col_range = get_range center_col, partial_width, avail_width + def height + @size.height + end - [row_range, col_range] + def width + @size.width end # This determines the maximum amount of room left available for Content # after taking into consideration the height of the Header and Footer def available_space header, footer - trim = Size [header.height + footer.height, 0] + trim = Tuple [@header.height + @footer.height, 0] size - trim end - - def size - Console.size - end - - def get_range offset, actual, available - # if the actual content can fit into the available space, then we're done - return (0...actual) if actual <= available - - # otherwise start looking at the scrolling offset, if any - - # clamp the offset within the possible range of the actual content - if offset < 0 then - range_start = 0 - elsif offset > actual then - range_start = actual - else - range_start = offset - end - - # determine the subset of content that can be displayed - range_end = range_start + (available - offset) - - (range_start...range_end) - end - end end diff --git a/spec/align_spec.rb b/spec/align_spec.rb new file mode 100644 index 0000000..6200ad0 --- /dev/null +++ b/spec/align_spec.rb @@ -0,0 +1,16 @@ +require_relative "spec_helper" +require "remedy/align" + +describe Remedy::Align do + subject(:a){ described_class } + + describe ".h_center_pad" do + it "centers a single string horizontally with padding" do + expected = " foo " + actual = a.h_center_p "foo", Tuple(10,5) + + expect(actual).to eq expected + end + end + +end diff --git a/spec/frame_align_spec.rb b/spec/frame_align_spec.rb new file mode 100644 index 0000000..80dc252 --- /dev/null +++ b/spec/frame_align_spec.rb @@ -0,0 +1,269 @@ +require_relative "spec_helper" +require "remedy/frame" + +describe Remedy::Frame do + let(:sizeclass) { ::Remedy::Tuple } + let(:console_size) { sizeclass.new 6, 6 } + subject(:f) do + f0 = described_class.new name: "subject" + f0.available_size = console_size + f0 + end + + before do + f << "foo" + f << "bar\nbaz" + end + + context "stacked arrangement" do + before do + f.arrangement = :stacked + end + + context "fill size" do + before do + f.size = :fill + end + + it "does all the things" do + topleft = "foo \nbar \nbaz \n \n \n " + topcenter = " foo \n bar \n baz \n \n \n " + topright = " foo\n bar\n baz\n \n \n " + + centerleft = " \nfoo \nbar \nbaz \n \n " + centercenter = " \n foo \n bar \n baz \n \n " + centerright = " \n foo\n bar\n baz\n \n " + + bottomleft = " \n \n \nfoo \nbar \nbaz " + bottomcenter = " \n \n \n foo \n bar \n baz " + bottomright = " \n \n \n foo\n bar\n baz" + + actual = f.to_s + expect(actual).to eq topleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq topcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq topright + + f.valign = :center + + f.halign = :left + actual = f.to_s + expect(actual).to eq centerleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq centercenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq centerright + + f.valign = :bottom + + f.halign = :left + actual = f.to_s + expect(actual).to eq bottomleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq bottomcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq bottomright + end + end + + context "fixed size" do + before do + f.size = Tuple 5, 5 + end + + it "does all the things" do + topleft = "foo \nbar \nbaz \n \n " + topcenter = " foo \n bar \n baz \n \n " + topright = " foo\n bar\n baz\n \n " + + centerleft = " \nfoo \nbar \nbaz \n " + centercenter = " \n foo \n bar \n baz \n " + centerright = " \n foo\n bar\n baz\n " + + bottomleft = " \n \nfoo \nbar \nbaz " + bottomcenter = " \n \n foo \n bar \n baz " + bottomright = " \n \n foo\n bar\n baz" + + actual = f.to_s + expect(actual).to eq topleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq topcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq topright + + f.valign = :center + + f.halign = :left + actual = f.to_s + expect(actual).to eq centerleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq centercenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq centerright + + f.valign = :bottom + + f.halign = :left + actual = f.to_s + expect(actual).to eq bottomleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq bottomcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq bottomright + end + end + + end + + context "columnar arrangement" do + let(:console_size) { sizeclass.new 4, 8 } + before do + f.arrangement = :columnar + end + + context "fill size" do + before do + f.size = :fill + end + + it "does all the things" do + topleft = "foobar \n baz \n \n " + topcenter = " foobar \n baz \n \n " + topright = " foobar\n baz\n \n " + + centerleft = " \nfoobar \n baz \n " + centercenter = " \n foobar \n baz \n " + centerright = " \n foobar\n baz\n " + + bottomleft = " \n \nfoobar \n baz " + bottomcenter = " \n \n foobar \n baz " + bottomright = " \n \n foobar\n baz" + + actual = f.to_s + expect(actual).to eq topleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq topcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq topright + + f.valign = :center + + f.halign = :left + actual = f.to_s + expect(actual).to eq centerleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq centercenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq centerright + + f.valign = :bottom + + f.halign = :left + actual = f.to_s + expect(actual).to eq bottomleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq bottomcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq bottomright + end + end + + context "fixed size" do + before do + f.size = Tuple 5, 7 + end + + it "does all the things" do + topleft = "foobar \n baz \n \n \n " + topcenter = "foobar \n baz \n \n \n " + topright = " foobar\n baz\n \n \n " + + centerleft = " \nfoobar \n baz \n \n " + centercenter = " \nfoobar \n baz \n \n " + centerright = " \n foobar\n baz\n \n " + + bottomleft = " \n \n \nfoobar \n baz " + bottomcenter = " \n \n \nfoobar \n baz " + bottomright = " \n \n \n foobar\n baz" + + actual = f.to_s + expect(actual).to eq topleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq topcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq topright + + f.valign = :center + + f.halign = :left + actual = f.to_s + expect(actual).to eq centerleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq centercenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq centerright + + f.valign = :bottom + + f.halign = :left + actual = f.to_s + expect(actual).to eq bottomleft + + f.halign = :center + actual = f.to_s + expect(actual).to eq bottomcenter + + f.halign = :right + actual = f.to_s + expect(actual).to eq bottomright + end + end + + end + +end diff --git a/spec/frame_origin_spec.rb b/spec/frame_origin_spec.rb new file mode 100644 index 0000000..0f9ace4 --- /dev/null +++ b/spec/frame_origin_spec.rb @@ -0,0 +1,149 @@ +require_relative "spec_helper" +require "remedy/frame" + +describe Remedy::Frame do + let(:sizeclass) { ::Remedy::Tuple } + let(:console_size) { sizeclass.new 6, 6 } + subject(:f) do + f0 = described_class.new name: "subject" + f0.available_size = console_size + f0.arrangement = :arbitrary + f0.size = :fill + f0 + end + + let(:f1) do + f1 = described_class.new name: "f1" + f1 << "a" + f1.size = Tuple 3, 3 + f1.fill = ":" + f1.valign = :center + f1.halign = :center + f1 + end + let(:f2) do + f2 = described_class.new name: "f2" + f2 << "b" + f2.size = Tuple 3, 3 + f2.fill = "*" + f2.valign = :center + f2.halign = :center + f2 + end + let(:f3) do + f3 = described_class.new name: "f3" + f3 << "c" + f3.size = Tuple 3, 3 + f3.fill = "#" + f3.valign = :center + f3.halign = :center + f3 + end + + before do + f << f1 + end + + it "does all the things" do + topleft = "::: \n:a: \n::: \n \n \n " + topcenter = " ::: \n :a: \n ::: \n \n \n " + topright = " :::\n :a:\n :::\n \n \n " + + centerleft = " \n::: \n:a: \n::: \n \n " + centercenter = " \n ::: \n :a: \n ::: \n \n " + centerright = " \n :::\n :a:\n :::\n \n " + + bottomleft = " \n \n \n::: \n:a: \n::: " + bottomcenter = " \n \n \n ::: \n :a: \n ::: " + bottomright = " \n \n \n :::\n :a:\n :::" + + actual = f.to_s + expect(actual).to eq topleft + + f1.horigin = :center + actual = f.to_s + expect(actual).to eq topcenter + + f1.horigin = :right + actual = f.to_s + expect(actual).to eq topright + + f1.vorigin = :center + + f1.horigin = :left + actual = f.to_s + expect(actual).to eq centerleft + + f1.horigin = :center + actual = f.to_s + expect(actual).to eq centercenter + + f1.horigin = :right + actual = f.to_s + expect(actual).to eq centerright + + f1.vorigin = :bottom + + f1.horigin = :left + actual = f.to_s + expect(actual).to eq bottomleft + + f1.horigin = :center + actual = f.to_s + expect(actual).to eq bottomcenter + + f1.horigin = :right + actual = f.to_s + expect(actual).to eq bottomright + end + + context "dynamic nested frame size" do + before do + f1.horigin = :center + f1.size = Tuple 0, 0.5 + end + + it "is centered" do + expected = " ::: \n ::: \n :a: \n ::: \n ::: \n ::: " + + actual = f.to_s + expect(actual).to eq expected + end + end + + context "available_size.zero? = true" do + before do + f.available_size = sizeclass.zero + f1.vorigin = :bottom + f1.horigin = :center + end + + context "size is Tuple" do + before do + f.size = console_size + end + + it "still puts the nested frame at the bottom" do + expected = " \n \n \n ::: \n :a: \n ::: " + actual = f.to_s + expect(actual).to eq expected + end + end + + context "size = :none" do + before do + f.size = :none + f1.depth = 2 + f2.size = Tuple 2,1 + f << f2 + end + + it "puts the frame at the bottom of the actual space" do + expected = "b \n* \n:::\n:a:\n:::" + actual = f.to_s + expect(actual).to eq expected + end + end + end + +end diff --git a/spec/frame_size_spec.rb b/spec/frame_size_spec.rb new file mode 100644 index 0000000..52d8328 --- /dev/null +++ b/spec/frame_size_spec.rb @@ -0,0 +1,83 @@ +require_relative "spec_helper" +require "remedy/frame" + +describe Remedy::Frame do + let(:sizeclass) { ::Remedy::Tuple } + let(:console_size) { sizeclass.new 6, 6 } + subject(:f) do + f0 = described_class.new name: "subject" + f0.available_size = console_size + f0 + end + + before do + f << "foo" + f << "bar\nbaz" + end + + context "stacked arrangement" do + before do + f.arrangement = :stacked + end + + it "occupies the size specified" do + expected = "foo \nbar \nbaz \n \n " + f.size = Tuple 5, 5 + actual = f.to_s + expect(actual).to eq expected + end + + describe "#resize" do + let(:new_size) { sizeclass.new 5, 5 } + + before do + f.size = :fill + end + + it "resizes the buffer" do + f.compile_contents + expect(f.compute_actual_size).to eq console_size + f.available_size = new_size + expect(f.compute_actual_size).to eq new_size + f.compile_contents # buffer size is not updated until recompile + expect(f.buffer.size).to eq new_size + end + end + + it "does all the things" do + none = "foo\nbar\nbaz" + fill = "foo:::\nbar:::\nbaz:::\n::::::\n::::::\n::::::" + auto = "foo \nbar \nbaz \n \n \n " # ?? + t5x5 = "foo::\nbar::\nbaz::\n:::::\n:::::" + tzxs = "f…\nb…\nb…\n::\n::\n::" + tbxf = "foo\nbar\nbaz\n:::\n:::\n:::\n:::" + + f.fill = ":" + + f.size = :none + actual = f.to_s + expect(actual).to eq none + + f.size = :fill + actual = f.to_s + expect(actual).to eq fill + + f.size = :auto + actual = f.to_s + #expect(actual).to eq auto + + f.size = Tuple 5, 5 + actual = f.to_s + expect(actual).to eq t5x5 + + f.size = Tuple 0, 2 + actual = f.to_s + expect(actual).to eq tzxs + + f.size = Tuple 7, 0.5 + actual = f.to_s + expect(actual).to eq tbxf + end + end + +end diff --git a/spec/frame_spec.rb b/spec/frame_spec.rb new file mode 100644 index 0000000..1a6f79d --- /dev/null +++ b/spec/frame_spec.rb @@ -0,0 +1,489 @@ +require_relative "spec_helper" +require "remedy/frame" +require "remedy/partial" + +describe Remedy::Frame do + let(:sizeclass) { ::Remedy::Tuple } + let(:console_size) { sizeclass.new 20, 40 } + subject(:f) do + f0 = described_class.new name: "subject" + f0.available_size = console_size + f0 + end + + let(:f1) do + f1 = described_class.new name: "f1" + f1 << "a" + f1.size = Tuple 3, 3 + f1.fill = ":" + f1.valign = :center + f1.halign = :center + f1 + end + let(:f2) do + f2 = described_class.new name: "f2" + f2 << "b" + f2.size = Tuple 3, 3 + f2.fill = "*" + f2.valign = :center + f2.halign = :center + f2 + end + let(:f3) do + f3 = described_class.new name: "f3" + f3 << "c" + f3.size = Tuple 3, 3 + f3.fill = "#" + f3.valign = :center + f3.halign = :center + f3 + end + + describe "#content_size" do + it "returns a Tuple of the contents dimensions" do + expected = Tuple 2, 4 + f << "1234" + f << "567" + + actual = f.content_size + expect(actual).to eq expected + end + end + + describe "#compute_actual_size" do + it "returns a Tuple of the rendered size" do + f << "1234" + f << "567" + arranged_size = Tuple(5, 5) + + f.size = :none + expected = Tuple 2, 4 + actual = f.compute_actual_size arranged_size + expect(actual).to eq expected + + f.size = :fill + actual = f.compute_actual_size arranged_size + expect(actual).to eq console_size + + f.size = :auto + actual = f.compute_actual_size arranged_size + expect(actual).to eq arranged_size + + f.size = sizeclass.zero + actual = f.compute_actual_size arranged_size + expect(actual).to eq console_size + + f.size = sizeclass.new 2, 2 + actual = f.compute_actual_size arranged_size + expect(actual).to eq sizeclass.new(2, 2) + + f.size = sizeclass.new 0.5, 0.74 + actual = f.compute_actual_size arranged_size + expect(actual).to eq sizeclass.new(10, 29) + end + end + + describe "#to_s" do + it "returns a string" do + expected = String + actual = f.to_s.class + expect(actual).to eq expected + end + end + + describe "#to_a" do + it "returns an array" do + expected = Array + actual = f.to_a.class + expect(actual).to eq expected + end + end + + describe "#compile_contents" do + it "compiles the contents of a single string" do + expected = "foo" + content = "foo" + f << content + actual = f.to_s + expect(actual).to eq expected + end + + it "compiles the contents of multiple strings" do + expected = "foo\nbar\nbaz" + f << "foo" + f << "bar" + f << "baz" + actual = f.to_s + expect(actual).to eq expected + end + + it "compiles the contents of partials" do + expected = "foo\nbar" + f << "foo" + f << ::Remedy::Partial.new(["bar"]) + actual = f.to_s + expect(actual).to eq expected + end + end + + describe "size and alignment" do + + context "size = Tuple" do + before do + f.size = Tuple 5, 5 + f.available_size = Tuple 7, 7 + + f << "foo" + f << "bar" + f << "baz" + end + + context "halign = :left" do + before do + f.halign = :left + end + + it "fills and aligns contents to the left" do + expected = "foo \nbar \nbaz \n \n " + + actual = f.to_s + expect(actual).to eq expected + end + end + end + + context "size = :fill" do + before do + f.size = :fill + f.available_size = Tuple 6, 6 + + f << "foo" + f << "bar" + f << "baz" + end + + context "halign = :left" do + before do + f.halign = :left + end + + it "fills and aligns contents to the left" do + expected = "foo \nbar \nbaz \n \n \n " + + actual = f.to_s + expect(actual).to eq expected + end + end + + context "halign = :right" do + before do + f.halign = :right + end + + it "fills and aligns contents to the right" do + expected = " foo\n bar\n baz\n \n \n " + + actual = f.to_s + expect(actual).to eq expected + end + end + + context "halign = :center" do + before do + f.halign = :center + end + + it "fills and aligns contents to the center" do + expected = " foo \n bar \n baz \n \n \n " + + actual = f.to_s + expect(actual).to eq expected + end + end + end + + context "size = :auto" do + before do + f.size = :auto + f.available_size = Tuple 6, 6 + + f << "foo" + f << "bar" + f << "bazyx" + end + + context "halign = :left" do + before do + f.halign = :left + end + + it "aligns contents to the left" do + expected = "foo \nbar \nbazyx" + + actual = f.to_s + expect(actual).to eq expected + end + end + + context "halign = :right" do + before do + f.halign = :right + end + + it "aligns contents to the right" do + expected = " foo\n bar\nbazyx" + + actual = f.to_s + expect(actual).to eq expected + end + end + + context "halign = :center" do + before do + f.halign = :center + end + + it "aligns contents to the center" do + expected = " foo \n bar \nbazyx" + + actual = f.to_s + expect(actual).to eq expected + end + end + end + + describe "0 height Tuple" do + before do + f.halign = :center + f.valign = :bottom + f.size = Tuple(0,7) + f.available_size = Tuple(3,11) + + f << "lol" + end + it "stretches to the vertical bounds of the container" do + expected = [ + " ", + " ", + " lol " + ].join ?\n + + actual = f.to_s + expect(actual).to eq expected + end + end + + describe "0 width tuple" do + before do + f.halign = :center + f.valign = :bottom + f.size = Tuple(1,0) + f.available_size = Tuple(3,11) + + f << "lol" + end + + it "stretches to the horizontal bounds of the container" do + expected = [ + " lol " + ].join ?\n + + actual = f.to_s + expect(actual).to eq expected + end + end + + describe "fractional width size" do + before do + f.halign = :center + f.valign = :bottom + f.size = Tuple(0,0.5) + f.available_size = Tuple(3,11) + + f << "lol" + end + + it "stretches to half the horizontal bounds of the container" do + expected = [ + " ", + " ", + " lol " + ].join ?\n + + actual = f.to_s + expect(actual).to eq expected + end + end + + describe "fractional height size" do + before do + f.halign = :center + f.valign = :bottom + f.size = Tuple(0.5,0) + f.available_size = Tuple(4,11) + + f << "lol" + end + + it "stretches to half the vertical bounds of the container" do + expected = [ + " ", + " lol " + ].join ?\n + + actual = f.to_s + expect(actual).to eq expected + end + end + end + + describe "arrangement" do + context "with strings" do + before do + f.reset! + f << "a" + f << "b" + f << "c" + end + + context "arrangement = stacked" do + before do + f.arrangement = :stacked + end + + it "arranges contents on top of each other" do + expected = "a\nb\nc" + actual = f.to_s + expect(actual).to eq expected + end + end + + context "arrangement = columnar" do + before do + f.arrangement = :columnar + end + + it "arranges contents next to each other" do + expected = "abc" + actual = f.to_s + expect(actual).to eq expected + end + end + end + end + + context "with nested frames" do + before do + f.reset! + f << f1 + f << f2 + f << f3 + + f1.size = :none + f2.size = :none + end + + context "arrangement = stacked" do + before do + f.arrangement = :stacked + end + + it "arranges contents on top of each other" do + expected = "a\nb\n###\n#c#\n###" + actual = f.to_s + expect(actual).to eq expected + end + end + + context "arrangement = columnar" do + before do + f.arrangement = :columnar + end + + it "arranges contents next to each other" do + expected = "ab###\n #c#\n ###" + actual = f.to_s + expect(actual).to eq expected + end + end + + context "arrangement = arbitrary" do + before do + f.arrangement = :arbitrary + f.available_size = sizeclass.zero + f1.depth = 3 + f1.size = Tuple 1,2 + f2.size = Tuple 2,1 + end + + it "contents are not relative to others" do + # f1e = "a:" + # f2e = "b\n*" # completely covered + # f3e = "###\n#c#\n###" + + expected = "a:#\n#c#\n###" + + actual = f.to_s + expect(actual).to eq expected + end + end + end + + describe "layering" do + let(:console_size){ Tuple 7, 7 } + + before do + f.reset! + f << f1 + f << f2 + f << f3 + + f2.depth = 1 + f2.offset = Tuple 2, 2 + f3.depth = 2 + f3.offset = Tuple 4, 4 + + f.size = :fill + f.arrangement = :arbitrary + f.fill = "." + end + + let(:expected) do + [ + ":::....", + ":a:....", + "::***..", + "..*b*..", + "..**###", + "....#c#", + "....###" + ].join ?\n + end + + it "places frames on top of each other according to their depth and order" do + actual = f.to_s + expect(actual).to eq expected + end + + it "treats plain strings as layer 0" do + f.reset! + f << f2 + f << f3 + f << f1.to_s + + actual = f.to_s + expect(actual).to eq expected + end + + context "negative depth" do + before do + f1.depth = -1 + end + + it "places frames properly" do + actual = f.to_s + expect(actual).to eq expected + end + end + end +end diff --git a/spec/screen_spec.rb b/spec/screen_spec.rb new file mode 100644 index 0000000..deb8926 --- /dev/null +++ b/spec/screen_spec.rb @@ -0,0 +1,242 @@ +require_relative "spec_helper" +require "remedy/screen" +require "remedy/frame" +require "remedy/partial" +require "stringio" + +describe Remedy::Screen do + subject(:s){ described_class.new auto_resize: false } + let(:console){ ::Remedy::Console } + let(:fclass) { ::Remedy::Frame } + let(:size){ Tuple 20, 40 } + let(:size_override){ Tuple 20, 40 } + let(:frame) do + f = fclass.new + f << "foo" + f << "bar\nbaz" + f.vorigin = :center + f.horigin = :center + f.valign = :center + f.halign = :center + f.size = :none + f + end + + before(:each) do + console.size_override = size_override + s.resize size_override, redraw: false + end + + context "captured STDIO" do + let(:stringio){ StringIO.new.tap{|sio| def sio.ioctl(magic, str); str = [20, 40, 0, 0].pack('SSSS'); 1; end } } + + before(:each) do + console.input = stringio + console.output = stringio + s.resize size, redraw: true # why does this need to be set to true?? + stringio.string = "" + end + + after(:each) do + console.input = $stdin + console.output = $stdout + console.size_override = nil + end + + describe "#draw" do + context "tiny 2x2 screen" do + let(:size_override){ Tuple 2, 2 } + + it "writes the buffer to the output" do + expected = "\e[H\e[J..\e[1B\e[0G..".inspect[1..-2] + + s.draw + + actual = stringio.string.inspect[1..-2] + expect(actual).to eq expected + end + end + + context "small 3x20 screen" do + let(:size_override){ Tuple 3, 20 } + + it "can display single objects with the override parameter" do + expected = "\\e[H\\e[J....................\\e[1B\\e[0G...hello, world!....\\e[1B\\e[0G...................." + value = "hello, world!" + + s.draw ::Remedy::Partial.new [value] + + actual = stringio.string.inspect[1..-2] + + expect(actual).to match value + expect(actual).to eq expected + end + end + end + end + + describe "#frames" do + let(:size){ Tuple 3, 5 } + let(:size_override){ size } + + let(:frame) do + f = fclass.new + f << "foo" + f << "bar\nbaz" + f + end + + it "gets the list of frames" do + expected = [frame] + + s.frames << frame + + actual = s.frames + expect(actual).to eq expected + end + + it "can add a single frame to the screen" do + expected = "foo..\nbar..\nbaz.." + s.frames << frame + actual = s.to_s + expect(actual).to eq expected + end + end + + describe "#resize" do + let(:new_size_override){ Tuple 5, 9 } + + before do + frame.size = Tuple(0, 0.5) + frame.fill = "." + s.mainframe.fill = " " + s.frames << frame + end + + it "resizes internal frames" do + # This is a very surface level test which did not detect a bug caused by + # Tuple#dup, but still demonstrates the basics + expected = [ + " .... ", + " foo. ", + " bar. ", + " baz. ", + " .... " + ].join ?\n + + s.resize new_size_override, redraw: false + actual = s.to_s + + expect(actual).to eq expected + end + end + + describe "offset frames" do + let(:size_override){ Tuple 5, 9 } + + before do + frame.vorigin = :bottom + frame.horigin = :right + frame.halign = :center + frame.valign = :center + frame.offset = Tuple -1, -2 + frame.size = Tuple(4, 0.5) + frame.fill = "." + s.mainframe.fill = " " + s.frames << frame + end + + it "moves the frame away from the point of origin" do + + expected = [ + " foo. ", + " bar. ", + " baz. ", + " .... ", + " " + ].join ?\n + + actual = s.to_s + + expect(actual).to eq expected + end + end + + describe "layering" do + let(:size_override){ Tuple 7, 7 } + let(:f1) do + f1 = fclass.new + f1.valign = :center + f1.halign = :center + f1 << "a" + f1.size = Tuple 3, 3 + f1.fill = ":" + f1 + end + let(:f2) do + f2 = fclass.new + f2.valign = :center + f2.halign = :center + f2 << "b" + f2.size = Tuple 3, 3 + f2.offset = Tuple 2, 2 + f2.fill = "*" + f2.depth = 1 + f2 + end + let(:f3) do + f3 = fclass.new + f3.valign = :center + f3.halign = :center + f3 << "c" + f3.size = Tuple 3, 3 + f3.offset = Tuple 4, 4 + f3.fill = "#" + f3.depth = 2 + f3 + end + + before do + s.frames.clear + s.frames << f1 + s.frames << f2 + s.frames << f3 + end + + it "places frames on top of each other according to their depth and order" do + expected = [ + ":::....", + ":a:....", + "::***..", + "..*b*..", + "..**###", + "....#c#", + "....###" + ].join ?\n + + actual = s.to_s + expect(actual).to eq expected + end + + context "f2.depth = 3" do + before do + f2.depth = 3 + end + + it "places frames on top of each other according to their depth and order" do + expected = [ + ":::....", + ":a:....", + "::***..", + "..*b*..", + "..***##", + "....#c#", + "....###" + ].join ?\n + + actual = s.to_s + expect(actual).to eq expected + end + end + end +end diff --git a/spec/screenbuffer_spec.rb b/spec/screenbuffer_spec.rb new file mode 100644 index 0000000..00accf1 --- /dev/null +++ b/spec/screenbuffer_spec.rb @@ -0,0 +1,203 @@ +require_relative 'spec_helper' +require 'remedy/screenbuffer' +require "remedy/partial" +require "remedy/view" + +describe Remedy::Screenbuffer do + subject(:sb){ described_class.new size, fill: "." } + let(:size){ Tuple 2, 2 } + + describe "#to_s" do + it "dumps the screenbuffer as a single string" do + expected = "..\n.." + actual = sb.to_s + expect(actual).to eq expected + end + end + + describe "#to_a" do + it "dumps the screenbuffer" do + expected = ["..", ".."] + actual = sb.to_a + expect(actual).to eq expected + end + + context "allocated size is larger than set size" do + it "dumps the screenbuffer" do + sb.resize Tuple 5, 5 + expected = [".....", ".....", ".....", ".....", "....."] + actual = sb.to_a + expect(sb.buf.length).to eq 5 + expect(actual).to eq expected + + sb.resize Tuple 3, 3 + expected = ["...", "...", "..."] + actual = sb.to_a + expect(sb.buf.length).to eq 5 + expect(actual).to eq expected + end + end + + end + + describe "#[]" do + it "returns the character at a particular location" do + expected = "x" + coords = ::Remedy::Tuple.tuplify 1, 1 + sb.buf = [ + "ab", + "yx" + ] + + actual = sb[coords] + expect(actual).to eq expected + end + end + + describe "#[]=" do + it "accepts Tuples as coordinates" do + value = "x" + expected = "..\n.#{value}" + coords = ::Remedy::Tuple.tuplify 1, 1 + sb[coords] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "sets the value at a particular location for a single character" do + value = "x" + expected = "..\n.#{value}" + sb[1,1] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "sets the value at a particular location for sequential characters" do + value = "xy" + expected = "..\n#{value}" + sb[1,0] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "sets the value at a particular location for multiple lines" do + value = %w{a b} + expected = "a.\nb." + sb[0,0] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "handles embedded newlines gracefully" do + value = "a\nb" + expected = ".a\n.b" + sb[0,1] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "accepts partials" do + value = Remedy::Partial.new ["a\nb"] + expected = ".a\n.b" + sb[0,1] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "accepts views" do + value = Remedy::View.new Remedy::Partial.new ["a\nb"] + expected = ".a\n.b" + sb[0,1] = value + actual = sb.to_s + expect(actual).to eq expected + end + + context "larger size" do + let(:size){ Tuple 4, 4 } + + it "handles embedded newlines gracefully for multiple lines" do + value = ["a\nb", "c"] + expected = "....\n...a\n...b\n...c" + sb[1,3] = value + actual = sb.to_s + expect(actual).to eq expected + end + end + + it "truncates horizontal overflows" do + value = "1234" + expected = "1…\n.." + sb[0,0] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "truncates vertical overflows" do + value = %w(1 2 3) + expected = "..\n1." + sb[1,0] = value + actual = sb.to_s + expect(actual).to eq expected + end + + it "truncates content when passed a negative index" do + # this enabled resizing the terminal smaller than fixed content sizes + # and moving windows partially off screen + + value = %w(1 2 3) + expected = "2.\n3." + sb[-1,0] = value + actual = sb.to_s + expect(actual).to eq expected + end + + context "without ellipsis" do + subject(:sb){ described_class.new size, fill: ".", ellipsis: nil } + + it "truncates horizontal overflows" do + value = "1234" + expected = "12\n.." + sb[0,0] = value + actual = sb.to_s + expect(actual).to eq expected + end + end + + it "can be resized" do + expected = ".....\n....." + sb.size = Tuple 2, 5 + actual = sb.to_s + expect(actual).to eq expected + end + + it "generates terminal safe output strings" do + expected = "..\e[1B\e[0G.." + actual = sb.to_ansi + expect(actual).to eq expected + end + end + + describe "#reset" do + it "resets the contents of the buffer" do + expected = "..\n.." + + sb[0,0] = ["ab", "cd"] + expect(sb.to_s).to eq "ab\ncd" + + sb.reset! + actual = sb.to_s + + expect(actual).to eq expected + end + end + + describe "#resize" do + xit "extends the buffer without destroying the contents" + xit "updates the size field properly" + xit "outputs constrained array after shrinking" + end + + describe "option fit = true" do + xit "automatically grows the buffer when content does not fit" + end +end diff --git a/spec/tuple_spec.rb b/spec/tuple_spec.rb new file mode 100644 index 0000000..159c9db --- /dev/null +++ b/spec/tuple_spec.rb @@ -0,0 +1,268 @@ +require_relative "spec_helper" +require "remedy/tuple" + +describe Remedy::Tuple do + subject(:t){ described_class.new 1, 1 } + + describe "#==" do + context "when other Tuple is the same" do + let(:other){ described_class.new 1, 1 } + + it "can tell when another Tuple has the same dimensional coordinates" do + expect(t == other).to be true + end + end + + context "when other is an Array" do + let(:other){ [1,1] } + + it "can tell that they are the same" do + expect(t == other).to be true + end + end + + context "when other Tuple has a different y" do + let(:other){ described_class.new 1, 2 } + + it "can tell that another Tuple is different" do + expect(t == other).to be false + end + end + + context "when other Tuple has a different cardinality" do + let(:other){ described_class.new 1, 1, 1 } + + it "can tell that another Tuple is different" do + expect(t == other).to be false + end + end + end + + describe "#dup" do + let(:other){ t.dup } + + it "creates a new dimension array" do + expect(other[1] == t[1]).to be true + t[1] = 88 + expect(other[1] == t[1]).to be false + end + end + + describe "#abs" do + it "returns a version of the tuple where all dimensions are positive" do + n = described_class.new -1, -0.5 + expected = described_class.new 1, 0.5 + actual = n.abs + expect(actual).to eq expected + end + end + + describe "#aod" do + context "other has same cardinality" do + let(:other) { described_class.new 0, 2 } + + it "returns a Tuple with the area of difference" do + expected = described_class.new 1, 1 + actual = t.aod other + expect(actual).to eq expected + end + end + + context "other has smaller cardinality" do + let(:other) { described_class.new 3 } + + it "returns a Tuple with the area of difference" do + expected = described_class.new 2, 1 + actual = t.aod other + expect(actual).to eq expected + end + end + + context "other has larger cardinality" do + let(:other) { described_class.new -1, 1, 3 } + + it "returns a Tuple with the area of difference" do + expected = described_class.new 2, 0, 3 + actual = t.aod other + expect(actual).to eq expected + end + end + end + + describe "#aold" do + context "other has same cardinality" do + let(:other) { described_class.new 0, 2 } + + it "returns a Tuple with the area of lesser difference" do + expected = described_class.new 1, 0 + actual = t.aold other + expect(actual).to eq expected + end + end + + context "other has smaller cardinality" do + let(:other) { described_class.new 3 } + + it "returns a Tuple with the area of lesser difference" do + expected = described_class.new 0, 1 + actual = t.aold other + expect(actual).to eq expected + end + end + + context "other has larger cardinality" do + let(:other) { described_class.new -1, 1, 3 } + + it "returns a Tuple with the area of lesser difference" do + expected = described_class.new 2, 0, 0 + actual = t.aold other + expect(actual).to eq expected + end + end + end + + describe "#aogd" do + context "other has same cardinality" do + let(:other) { described_class.new 0, 2 } + + it "returns a Tuple with the area of greater difference" do + expected = described_class.new 0, 1 + actual = t.aogd other + expect(actual).to eq expected + end + end + + context "other has smaller cardinality" do + let(:other) { described_class.new 3 } + + it "returns a Tuple with the area of greater difference" do + expected = described_class.new 2, 0 + actual = t.aogd other + expect(actual).to eq expected + end + end + + context "other has larger cardinality" do + let(:other) { described_class.new -1, 1, 3 } + + it "returns a Tuple with the area of greater difference" do + expected = described_class.new 0, 0, 3 + actual = t.aogd other + expect(actual).to eq expected + end + end + end + + describe "#zero?" do + it "returns true when all dimensions are zero" do + z = described_class.new 0, 0 + expected = true + actual = z.zero? + expect(actual).to eq expected + end + end + + describe "#nonzero?" do + it "returns true when any dimension is not zero" do + z = described_class.new 1, 0 + expected = true + actual = z.nonzero? + expect(actual).to eq expected + end + end + + describe "#<=>" do + context "other Tuple is larger" do + let(:other){ described_class.new 2, 2 } + + it "returns -1" do + expected = -1 + actual = t <=> other + expect(actual).to eq expected + end + + it "returns -1 due to magnitude" do + other = described_class.new 0, 3 + expected = -1 + actual = t <=> other + expect(actual).to eq expected + end + end + + context "other Tuple is smaller" do + let(:other){ described_class.new 0, 0 } + + it "returns 1 when the other tuple is lesser" do + expected = 1 + actual = t <=> other + expect(actual).to eq expected + end + end + + context "other Tuple is the same size" do + let(:other){ t.dup } + + it "returns 0" do + expected = 0 + actual = t <=> other + expect(actual).to eq expected + end + end + end + + describe "#fits_into?" do + context "other has same cardinality" do + context "and is bigger in one dimension but smaller in another" do + let(:other) { described_class.new 0, 2 } + + it "does not fit" do + expected = false + actual = t.fits_into? other + expect(actual).to eq expected + end + end + + context "and is bigger in one dimension but equal in another" do + let(:other) { described_class.new 1, 2 } + + it "returns a Tuple with the area of greater difference" do + expected = true + actual = t.fits_into? other + expect(actual).to eq expected + end + end + end + + context "other has smaller cardinality" do + let(:other) { described_class.new 3 } + + it "does not fit" do + expected = false + actual = t.fits_into? other + expect(actual).to eq expected + end + end + + context "other has larger cardinality" do + context "and has a negative dimension" do + let(:other) { described_class.new -1, 1, 3 } + + it "does not fit" do + expected = false + actual = t.fits_into? other + expect(actual).to eq expected + end + end + + context "has more dimensions which are the same size" do + let(:other) { described_class.new 1, 1, 1 } + + it "does fit" do + expected = true + actual = t.fits_into? other + expect(actual).to eq expected + end + end + end + end +end diff --git a/spec/viewport_spec.rb b/spec/viewport_spec.rb index 576d561..8e1f70d 100644 --- a/spec/viewport_spec.rb +++ b/spec/viewport_spec.rb @@ -1,19 +1,34 @@ -require_relative 'spec_helper' -require 'remedy/viewport' +require_relative "spec_helper" +require "remedy/viewport" describe Remedy::Viewport do + let(:console){ ::Remedy::Console } + let(:size_override){ Tuple 20, 40 } + let(:stringio){ StringIO.new } + + before(:each) do + console.input = stringio + console.output = stringio + console.size_override = size_override + end + + after(:each) do + console.input = $stdin + console.output = $stdout + console.size_override = nil + end + it 'should be able to execute the example code from the readme' do - @stdout = $stdout + expected = "\\e[H\\e[JQ: What's the difference between a duck?\\e[1B\\e[0GA: Purple, because ice cream has no bone\\e[1B\\e[0G" + joke = ::Remedy::Partial.new joke << "Q: What's the difference between a duck?" joke << "A: Purple, because ice cream has no bones!" screen = ::Remedy::Viewport.new - sio = StringIO.new - $stdout = sio - screen.draw joke unless ENV['CI'] - expect(sio.string).to include("ice cream") - ensure - $stdout = @stdout + screen.draw joke + + actual = stringio.string.inspect[1..-2] + expect(actual).to eq expected end end