defmodule Orange.Renderer do @moduledoc false alias Orange.Renderer.Buffer alias Orange.Layout.InputTreeNode @type window :: %{width: integer, height: integer} @type style_chain :: [Keyword.t()] @type ui_element :: Orange.Rect.t() # Render the elements to a buffer before painting them to the screen # A buffer is a m×n matrix of cells @spec render(ui_element, window) :: Buffer.t() def render(tree, window) do {tree, node_attributes_map, fixed_position_nodes} = to_binding_input_tree(tree) width = window[:width] height = window[:height] buffer = Buffer.new({width, height}) # The tree can be nil if the root element is a fixed position node buffer = if tree do output_tree = Orange.Layout.layout(tree, {width, height}) render_node(output_tree, buffer, {0, 0}, node_attributes_map, []) else buffer end Enum.reduce(fixed_position_nodes, buffer, fn node, acc -> render_fixed(node, acc, window) end) end defp render_node( %Orange.Layout.OutputTreeNode{} = node, buffer, origin, node_attributes_map, style_chain ) do node = node |> Map.update!(:x, &(&1 + elem(origin, 0))) |> Map.update!(:y, &(&1 + elem(origin, 1))) attributes = Map.get(node_attributes_map, node.id, []) buffer |> render_border(node, attributes) |> maybe_render_title(node, attributes[:title]) |> render_children(node, node_attributes_map, style_chain) |> maybe_set_background_color(node, attributes) end defp render_border( buffer, %Orange.Layout.OutputTreeNode{border: border, x: x, y: y, width: w, height: h}, attributes ) do %{top: top, right: right, bottom: bottom, left: left} = border border_color = get_in(attributes, [:style, :border_color]) # Top border buffer = if top > 0 do top_border = if(left > 0, do: "┌", else: "─") <> String.duplicate("─", w - 2) <> if(right > 0, do: "┐", else: "─") Buffer.write_string(buffer, {x, y}, top_border, :horizontal, color: border_color) else buffer end # Bottom border buffer = if bottom > 0 do bottom_border = if(left > 0, do: "└", else: "─") <> String.duplicate("─", w - 2) <> if(right > 0, do: "┘", else: "─") Buffer.write_string(buffer, {x, y + h - 1}, bottom_border, :horizontal, color: border_color ) else buffer end # Left and right border start = if top > 0, do: y + 1, else: y stop = if bottom > 0, do: y + h - 2, else: y + h - 1 length = stop - start + 1 vertical_border = String.duplicate("│", length) buffer = if left > 0 do Buffer.write_string(buffer, {x, start}, vertical_border, :vertical, color: border_color) else buffer end buffer = if right > 0 do Buffer.write_string(buffer, {x + w - 1, start}, vertical_border, :vertical, color: border_color ) else buffer end buffer end defp maybe_render_title(buffer, _, nil), do: buffer defp maybe_render_title(buffer, %Orange.Layout.OutputTreeNode{x: x, y: y}, title) do {title_text, offset, opts} = case title do title when is_binary(title) -> {title, 0, []} title when is_map(title) -> opts = title |> Map.take([:color, :text_modifiers]) |> Map.to_list() {title[:text], Map.get(title, :offset, 0), opts} end Buffer.write_string( buffer, {x + offset + 1, y}, title_text, :horizontal, opts ) end defp maybe_set_background_color( buffer, %Orange.Layout.OutputTreeNode{ x: x, y: y, width: w, height: h }, attributes ) do background_color = get_in(attributes, [:style, :background_color]) if background_color do area = %__MODULE__.Area{x: x, y: y, width: w, height: h} Buffer.set_background_color( buffer, area, background_color ) else buffer end end defp render_children(buffer, node, node_attributes_map, style_chain) do attributes = Map.get(node_attributes_map, node.id, []) scroll_x = attributes[:scroll_x] scroll_y = attributes[:scroll_y] if scroll_x || scroll_y, do: render_scrollable_children(buffer, node, node_attributes_map, style_chain), else: do_render_children(buffer, node, node_attributes_map, style_chain) end defp do_render_children(buffer, node, node_attributes_map, style_chain) do attributes = Map.get(node_attributes_map, node.id, []) style_chain = [Keyword.get(attributes, :style, []) | style_chain] case node.children do {:text, text} -> start_x = node.x + if(node.border.left > 0, do: 1, else: 0) + node.padding.left start_y = node.y + if(node.border.top > 0, do: 1, else: 0) + node.padding.top opts = [ color: get_style_from_chain(style_chain, :color), text_modifiers: get_style_from_chain(style_chain, :text_modifiers) || [] ] lines = string_to_lines( text, elem(node.content_size, 0) - node.padding.left - node.padding.right, elem(node.content_size, 1) - node.padding.top - node.padding.bottom ) {buffer, _} = Enum.reduce(lines, {buffer, 0}, fn line, {acc_buffer, index} -> updated_buffer = Buffer.write_string(acc_buffer, {start_x, start_y + index}, line, :horizontal, opts) {updated_buffer, index + 1} end) buffer {:nodes, nodes} -> # The position of each child is relative to the parent # We need to keep track of the origin of the parent. The new origin is the parent's position new_origin = {node.x, node.y} Enum.reduce(nodes, buffer, fn node, buffer -> render_node(node, buffer, new_origin, node_attributes_map, style_chain) end) end end # Given the content size calculated by the layout algorithm, split string into lines defp string_to_lines(string, width, height) do words = String.split(string, ~r/\s+/) {lines, current_line} = Enum.reduce(words, {[], ""}, fn word, {lines, current_line} -> new_width = String.length(current_line) + String.length(word) + 1 cond do # If the current line is empty, always append the word current_line == "" -> {lines, word} # From the second word onwards, check if the current line is too long new_width > width -> lines = lines ++ [current_line] {lines, word} true -> appended = if current_line == "", do: word, else: current_line <> " " <> word {lines, appended} end end) lines = if current_line != "", do: lines ++ [current_line], else: lines if height != length(lines), do: raise("#{__MODULE__}: Lines count does not match the layout height") lines end # The render algorithm is as follows: # 1. First render the scrollable children into a separate buffer. # 2. Extract the visible area from the children buffer. This area is determined by the scroll offset (x, y) and # the width and height of the parent node # 3. Merge the visible area into the parent buffer defp render_scrollable_children(buffer, node, node_attributes_map, style_chain) do scroll_buffer = Buffer.new() scroll_buffer = do_render_children( scroll_buffer, # Reset the parent container %{node | x: 0, y: 0, border: {0, 0, 0, 0}, padding: {0, 0, 0, 0}}, node_attributes_map, style_chain ) attributes = Map.get(node_attributes_map, node.id, []) merge_scrollable_children( buffer, scroll_buffer, node, {attributes[:scroll_x], attributes[:scroll_y]} ) end defp merge_scrollable_children(buffer, scrollable_buffer, node, {scroll_x, scroll_y}) do inner_width = node.width - node.padding.left - node.padding.right - node.border.left - node.border.right inner_height = node.height - node.padding.top - node.padding.bottom - node.border.top - node.border.bottom inner_start_x = (scroll_x || 0) + node.padding.left + node.border.left inner_start_y = (scroll_y || 0) + node.padding.top + node.border.top children_viewport = extract_buffer_viewport( scrollable_buffer, inner_start_x, inner_start_y, inner_width, inner_height ) offset_x = node.border.left + node.padding.left offset_y = node.border.top + node.padding.top Enum.with_index(children_viewport) |> Enum.reduce(buffer, fn {row, row_index}, acc -> Enum.with_index(row) |> Enum.reduce(acc, fn {cell, col_index}, acc -> if cell != :undefined do Buffer.write_cell( acc, {node.x + offset_x + col_index, node.y + offset_y + row_index}, cell ) else acc end end) end) end defp extract_buffer_viewport(buffer, x, y, width, height) do buffer.rows |> :array.to_list() |> Enum.slice(y, height) |> Enum.map(fn row -> :array.to_list(row) |> Enum.slice(x, width) end) end # Fixed position render algorithm: # 1. In the first render pass, all fixed position boxes will be collected and removed from the tree # 2. After the first pass, render each fixed position box, according to the order of appearance defp render_fixed(%Orange.Rect{} = rect, buffer, window) do # Fixed layer will overshadow the layer behind it {:fixed, top, right, bottom, left} = rect.attributes[:position] width = window[:width] - left - right height = window[:height] - top - bottom area = %__MODULE__.Area{ x: left, y: top, width: width, height: height } buffer = Buffer.clear_area(buffer, area) # Render as non-fixed position node rect = %{rect | attributes: Keyword.delete(rect.attributes, :position)} # The fixed node should have width/height defined by the fixed coordinates # The sizes should fill the available space style = rect.attributes[:style] || [] style = Keyword.merge(style, width: "100%", height: "100%") rect = %{rect | attributes: Keyword.put(rect.attributes, :style, style)} # If fixed position node has nested fixed position children, ignore them for now {tree, node_attributes_map, _fixed_position_nodes} = to_binding_input_tree(rect) output_tree = Orange.Layout.layout(tree, {width, height}) render_node(output_tree, buffer, {left, top}, node_attributes_map, []) end defp get_style_from_chain(style_chain, attribute), do: Enum.find_value(style_chain, &Keyword.get(&1, attribute)) defp to_binding_input_tree( _node, counter \\ :atomics.new(1, []), node_map \\ %{}, fixed_position_nodes \\ [] ) # Convert a component tree to a input tree to pass to the layout binding # Traverse the tree and convert recursively. During the traversal: # 1. Collect node attributes # 2. Collect fixed position nodes defp to_binding_input_tree(%Orange.Rect{} = node, counter, node_map, fixed_position_nodes) do new_id = :atomics.add_get(counter, 1, 1) style = if(node.attributes[:style], do: to_binding_style(node.attributes[:style])) # Collect fixed position nodes {new_node, node_map, fixed_position_nodes} = case node.attributes[:position] do {:fixed, _, _, _, _} -> {nil, node_map, fixed_position_nodes ++ [node]} _ -> case node.children do # Special if the node has a single text child # Instead of rect -> rect -> text, we will have rect -> text directly [text] when is_binary(text) -> {%Orange.Layout.InputTreeNode{ id: new_id, children: {:text, text}, style: style }, node_map, fixed_position_nodes} nodes -> {children, updated_node_map, updated_fixed_position_nodes} = Enum.reduce(nodes, {[], node_map, fixed_position_nodes}, fn node, {result, node_map_acc, fixed_position_nodes_acc} -> {new_node, new_node_map, new_fixed_position_nodes} = to_binding_input_tree(node, counter, node_map_acc, fixed_position_nodes_acc) # new_node can be nil if the node is a fixed position node result = if new_node, do: result ++ [new_node], else: result {result, new_node_map, new_fixed_position_nodes} end) { %Orange.Layout.InputTreeNode{ id: new_id, children: {:nodes, children}, style: style }, updated_node_map, updated_fixed_position_nodes } end end # Save node attributes node_map = Map.put(node_map, new_id, node.attributes) {new_node, node_map, fixed_position_nodes} end defp to_binding_input_tree(string, counter, node_map, fixed_position_nodes) do new_id = :atomics.add_get(counter, 1, 1) new_node = %Orange.Layout.InputTreeNode{ id: new_id, children: {:text, string}, style: nil } {new_node, node_map, fixed_position_nodes} end defp to_binding_style(style) do %InputTreeNode.Style{ width: parse_length_percentage(style[:width]), height: parse_length_percentage(style[:height]), border: expand_border(style), padding: expand_padding_margin(style[:padding]), margin: expand_padding_margin(style[:margin]), display: Keyword.get(style, :display, :flex), # Flex properties flex_direction: style[:flex_direction], flex_grow: style[:flex_grow], flex_shrink: style[:flex_shrink], justify_content: style[:justify_content], align_items: style[:align_items], line_wrap: Keyword.get(style, :line_wrap, true), # Grid properties grid_template_rows: parse_grid_tracks(style[:grid_template_rows]), grid_template_columns: parse_grid_tracks(style[:grid_template_columns]), grid_row: parse_grid_line_pair(style[:grid_row]), grid_column: parse_grid_line_pair(style[:grid_column]) } end defp parse_length_percentage(size) do cond do is_integer(size) -> {:fixed, size} is_binary(size) and String.ends_with?(size, "%") -> {float, "%"} = Float.parse(size) {:percent, float / 100} size == nil -> size end end defp expand_border(style) do border = fn position -> border_value = if style[:"border_#{position}"] != nil, do: style[:"border_#{position}"], else: style[:border] if border_value, do: 1, else: 0 end {border.(:top), border.(:right), border.(:bottom), border.(:left)} end defp expand_padding_margin(value) do case value do {vy, vx} -> {vy, vx, vy, vx} {_top, _right, _bottom, _left} -> value v when is_integer(v) -> {v, v, v, v} nil -> {0, 0, 0, 0} end end defp parse_grid_tracks(nil), do: nil defp parse_grid_tracks(tracks) when is_list(tracks) do Enum.map(tracks, fn :auto -> :auto {:repeat, count, track} when is_integer(count) -> [track] = parse_grid_tracks([track]) {:repeat, count, track} {:fr, v} when is_integer(v) -> {:fr, v} track -> # Otherwise, it must be fixed track size = parse_length_percentage(track) if size == nil, do: raise("Invalid grid track: #{inspect(track)}") size end) end defp parse_grid_line_pair(nil), do: nil # Single span defp parse_grid_line_pair({:span, span} = v) when is_integer(span), do: {:single, v} defp parse_grid_line_pair({start, end_}), do: {:double, parse_grid_line(start), parse_grid_line(end_)} defp parse_grid_line_pair(start), do: {:single, parse_grid_line(start)} defp parse_grid_line(line) when is_integer(line), do: {:fixed, line} defp parse_grid_line({:span, span}) when is_integer(span), do: {:span, span} defp parse_grid_line(:auto), do: :auto end