ndreynolds / ratatouille

A TUI (terminal UI) kit for Elixir
MIT License
752 stars 39 forks source link

Support offsetting content #9

Closed ndreynolds closed 5 years ago

ndreynolds commented 5 years ago

In order to implement scrolling in a UI, it should be possible to render content such that it's offset by some number of rows and/or columns.

Examples

Horizontal scrolling inside a table

The major use case I have in mind is horizontal scrolling within a table. For example, take the following table:

│ PID           Name or Initial Func                       Reds    Memory  MsgQ  Current Functio │
│ #PID<0.0.0>   init                                       3730    21680   0     init:boot_loop/ │
│ #PID<0.1.0>   erts_code_purger                           22934   26864   0     erts_code_purge │
│ #PID<0.2.0>   erts_literal_area_collector:start/0        22076   2688    0     erts_literal_ar │
│ #PID<0.3.0>   erts_dirty_process_signal_handler:start/0  575     2688    0     erts_dirty_proc │
│ #PID<0.4.0>   erts_dirty_process_signal_handler:start/0  46      2688    0     erts_dirty_proc │

The table will be further truncated whenever the screen width does not accomodate rendering additional columns, e.g.:

│ PID           Name or Initial Func                       R |
│ #PID<0.0.0>   init                                       3 |
│ #PID<0.1.0>   erts_code_purger                           2 |
│ #PID<0.2.0>   erts_literal_area_collector:start/0        2 |
│ #PID<0.3.0>   erts_dirty_process_signal_handler:start/0  5 |
│ #PID<0.4.0>   erts_dirty_process_signal_handler:start/0  4 |

It should be possible to change where the rendering starts by adding an offset on the x-axis. Here the offset is two columns of the terminal, meaning we've scrolled two columns to the right:

│ D           Name or Initial Func                       Red |
│ ID<0.0.0>   init                                       373 |
│ ID<0.1.0>   erts_code_purger                           229 |
│ ID<0.2.0>   erts_literal_area_collector:start/0        220 |
│ ID<0.3.0>   erts_dirty_process_signal_handler:start/0  575 |
│ ID<0.4.0>   erts_dirty_process_signal_handler:start/0  46  |

We can continue scrolling right by increasing the x offset to four columns:

│           Name or Initial Func                       Reds  │
│ <0.0.0>   init                                       3730  │
│ <0.1.0>   erts_code_purger                           22934 │
│ <0.2.0>   erts_literal_area_collector:start/0        22076 │
│ <0.3.0>   erts_dirty_process_signal_handler:start/0  575   │
│ <0.4.0>   erts_dirty_process_signal_handler:start/0  46    │

less/more-style pager

Similar to how we've applied an x-axis offset above, it can be helpful to apply an offset to the y-axis:

GREP(1)                 General Commands Manual                 GREP(1)

NAME
       grep, egrep, fgrep - print lines matching a pattern

SYNOPSIS
       grep [OPTIONS] PATTERN [FILE...]
       grep [OPTIONS] -e PATTERN ... [FILE...]
       grep [OPTIONS] -f FILE ... [FILE...]

DESCRIPTION
       grep  searches  for  PATTERN in each FILE.  A FILE of “-” stands
       for standard input.  If no FILE  is  given,  recursive  searches
       examine  the  working  directory, and nonrecursive searches read
       standard input.  By default, grep prints the matching lines.

With 2 rows of offset applied:

NAME
       grep, egrep, fgrep - print lines matching a pattern

SYNOPSIS
       grep [OPTIONS] PATTERN [FILE...]
       grep [OPTIONS] -e PATTERN ... [FILE...]
       grep [OPTIONS] -f FILE ... [FILE...]

DESCRIPTION
       grep  searches  for  PATTERN in each FILE.  A FILE of “-” stands
       for standard input.  If no FILE  is  given,  recursive  searches
       examine  the  working  directory, and nonrecursive searches read
       standard input.  By default, grep prints the matching lines.

       In addition, the variant programs egrep and fgrep are  the  same
       as  grep -E  and  grep -F,  respectively.   These  variants  are

Contrasting with Scrolling on the Web

While HTML defines a structured document, it makes few prescriptions as to how the content is actually rendered. In the case of a scrollbar, it's the web browser that ultimately decides to show a scrollbar based on directives from CSS and whether or not the content would overflow the allotted rendering region. How the browser's viewport looks at any given moment depends on the HTML, the CSS, running scripts, and the browser's own internal state and environment.

Ratatouille provides both a language for defining such a structured document and a rendering engine for rendering the document. Rendering in Ratatouille is a pure function. Rendering the same document onto the same canvas should always produce an identical resulting canvas. Whether the resulting canvas is then output to a terminal or as a string is just a small implementation detail. Unlike the browser, Ratatouille never directly changes what's rendered in response to user input. Rather, it only provides a pattern for it---an application needs to define a new document in response to the input event, and Ratatouille will render this new document. In this way, rendering and event handling are decoupled in Ratatouille.

This also ties back into scrolling. Since rendering in Ratatouille is just a pure function of a canvas and a document, any rendering directives (height, width, color, etc.) would need to be stored within the canvas (which stores dimensions and cells) or the document. Unlike the document, the canvas isn't hierarchical---it's only top-level information. That means we could store a scroll offset on the top-level, but not for nested elements such as a table within a tab pane.

Implementation

I think this means we need to somehow encode the offsets in the document itself, e.g.

table(offset_x: 5) do
  table_row do
    table_cell(content: "foo")
  end
end

A more elegant approach could be to define a container to handle offseting arbitrary child elements:

viewport(offset_x: 5, offset_y: 3)
  table do
    table_row do
      table_cell(content: "foo")
    end
  end
end

The rendering logic for the viewport element could initially be relatively simple. E.g., given a canvas with a rendering region of 100 columns by 40 rows, and a desired x-offset of 5 and y-offset of 3:

  1. Make a copy of the canvas with no cells and an adjusted rendering region of 105 columns and 43 rows.
  2. Render the viewport's content onto this copy.
  3. Shift all of the rendered cells in the copy by -5 columns and -3 rows.
  4. Merge the copy with the original canvas and return the result.