class Cani::Api::Feature::Viewer

Constants

COLORS
COLOR_PAIRS
COMPACT
ERAS
MARKET_SHARE_THRESHHOLD
NOTE_COLORS
PADDING
PERCENT_COLORS

Attributes

browsers[R]
col_width[R]
feature[R]
height[R]
table_width[R]
viewable[R]
width[R]

Public Class Methods

new(feature, browsers = Cani.api.browsers) click to toggle source
# File lib/cani/api/feature/viewer.rb, line 99
def initialize(feature, browsers = Cani.api.browsers)
  @feature  = feature
  @browsers = browsers
  @viewable = browsers.size

  resize

  Curses.init_screen
  Curses.curs_set 0
  Curses.noecho

  if Curses.has_colors?
    Curses.use_default_colors
    Curses.start_color
  end

  COLOR_PAIRS.each do |(cn, clp)|
    Curses.init_pair cn, *clp
  end

  trap('INT', &method(:close))
  at_exit(&method(:close))
end

Public Instance Methods

close(*_args) click to toggle source
# File lib/cani/api/feature/viewer.rb, line 123
def close(*_args)
  Curses.close_screen
end
color(key, type = :bg, source = COLORS) click to toggle source
# File lib/cani/api/feature/viewer.rb, line 411
def color(key, type = :bg, source = COLORS)
  target = key.to_s.downcase.to_sym
  type   = type.to_sym

  source.find { |(k, _)| k == target }.to_a
        .fetch(1, {})
        .fetch(type, source[:default][type])
end
colw() click to toggle source
# File lib/cani/api/feature/viewer.rb, line 387
def colw
  colw = PADDING * 2 + browsers[0..viewable].map(&:max_column_width).max

  colw.even? ? colw : colw + 1
end
compact?() click to toggle source
# File lib/cani/api/feature/viewer.rb, line 407
def compact?
  width < COMPACT
end
draw() click to toggle source
# File lib/cani/api/feature/viewer.rb, line 127
def draw
  Curses.clear

  outer_width   = table_width + 2
  percent_num   = format '%.2f%%', feature.percent
  status_format = "[#{feature.status}]"
  percent_label = compact? ? '' : 'support: '
  legend_format = 'legend'.center outer_width
  notes_format  = 'notes'.center outer_width

  offset_x      = ((width - outer_width) / 2.0).floor
  offset_y      = 1
  cy            = 0

  # positioning and drawing of percentage
  perc_num_xs = outer_width - percent_num.size
  Curses.setpos offset_y + cy, offset_x + perc_num_xs
  Curses.attron percent_color(feature.percent) do
    Curses.addstr percent_num
  end

  # positioning and drawing of 'support: ' text
  # ditch this part all together when in compact mode
  unless compact?
    perc_lbl_xs = perc_num_xs - percent_label.size
    Curses.setpos offset_y + cy, offset_x + perc_lbl_xs
    Curses.addstr percent_label
  end

  # draw possibly multi-line feature title
  title_size    = [table_width - percent_num.size - percent_label.size - status_format.size - 3, 1].max
  title_size   += status_format.size if compact?
  title_chunks  = feature.title.chars.each_slice(title_size).map(&:join)

  title_chunks.each do |part|
    Curses.setpos offset_y + cy, offset_x
    Curses.addstr part

    cy += 1
  end

  # status positioning and drawing
  # when compact? draw it on the second line instead of the
  # first line at the end of the title
  cy       += 1
  status_yp = offset_y + (compact? ? 1 : 0)
  status_xp = offset_x + (compact? ? table_width - status_format.size
                                   : [title_size, feature.title.size].min + 1)

  Curses.setpos status_yp, status_xp
  Curses.attron status_color(feature.status) do
    Curses.addstr status_format
  end

  # 'more or less' predict a height that is too small
  # since we don't know the entire height but draw
  # it line-by-line at the moment
  compact_height = height <= 40

  # by default, notes are only shown if visible in the actual table
  # this means there might be more notes than actually displayed
  # but this allows us to optimally use available space to display
  # the most useful information to the user
  # TODO: provide a config setting to disable this behaviour
  notes_visible = []

  # meaty part, loop through browsers to create
  # the final feature table
  relevant_era_count = browsers[0...viewable].map do |browser|
    era_idx   = browser.most_popular_era_idx
    era_range = (era_idx - (ERAS / 2.0).floor + 1)..(era_idx + (ERAS / 2.0).ceil)

    era_range.map do |cur_era|
      era = browser.eras[cur_era].to_s
      browser.usage[era].to_f >= MARKET_SHARE_THRESHHOLD || (!era.empty? && cur_era >= era_idx - 1)
    end.select { |x| x }.size
  end.max

  browsers[0...viewable].each.with_index do |browser, x|
    # some set up to find the current era for each browser
    # and creating a range around that to show past / coming support
    era_idx   = browser.most_popular_era_idx
    era_range = (era_idx - (relevant_era_count / 2.0).floor + 1)..(era_idx + (relevant_era_count / 2.0).ceil)
    bx        = offset_x + x * col_width + x
    by        = offset_y + cy

    # draw browser names
    Curses.setpos by, bx
    Curses.attron color(:header) do
      Curses.addstr browser.name.tr('_', '.').center(col_width)
    end

    # accordingly increment current browser y for the table header (browser names)
    # and an additional empty line below the table header
    by += 3

    # draw era's for the current browser
    era_range.each.with_index do |cur_era, y|
      era        = browser.eras[cur_era].to_s
      supp_type  = feature.support_in(browser.name, era)
      colr       = color supp_type
      is_current = cur_era == era_idx
      past_curr  = cur_era > era_idx
      top_pad    = 1
      bot_pad    = compact_height ? 0 : 1
      ey         = by + (y * (2 + top_pad + bot_pad)) + (bot_pad.zero? && past_curr ? 1 : 0)
      note_nums  = feature.browser_note_nums.fetch(browser.name, {})
                          .fetch(era, [])

      # do not draw era's that exceed screen height
      break if (ey + (is_current ? 1 : bot_pad) + 1) >= height

      # draw current era outline before drawing all era cells on top
      if is_current
        Curses.setpos ey - top_pad - 1, [bx - 1, 0].max
        Curses.attron(color(:era_border)) { Curses.addstr ' ' * (col_width + 2) }

        Curses.setpos ey + (is_current ? 1 : bot_pad) + 1, [bx - 1, 0].max
        Curses.attron(color(:era_border)) { Curses.addstr ' ' * (col_width + 2) }
      end

      # only show visible / relevant browsers
      # era's can either be empty or too new to determine
      # their usefulness by usage (when newer than current era).
      if browser.usage[era].to_f >= MARKET_SHARE_THRESHHOLD || (!era.empty? && cur_era >= era_idx - 1)
        ((ey - top_pad)..(ey + (is_current ? 1 : bot_pad))).each do |ry|
          txt = (bot_pad.zero? && !is_current) ? (ry >= ey + (is_current ? 1 : bot_pad) ? era.to_s : ' ')
                              : (ry == ey ? era.to_s : ' ')

          Curses.setpos ry, bx
          Curses.attron(colr) { Curses.addstr txt.center(col_width) }

          # draw current ara border inbetween the cells
          if is_current
            Curses.setpos ry, bx - 1
            Curses.attron(color(:era_border)) { Curses.addstr ' ' }

            Curses.setpos ry, offset_x + table_width + 2
            Curses.attron(color(:era_border)) { Curses.addstr ' ' }
          end
        end

        if note_nums.any?
          notes_visible.concat(note_nums).uniq!
          Curses.setpos ey - top_pad, bx
          Curses.attron(note_color(supp_type)) { Curses.addstr ' ' + note_nums.join(' ') }
        end
      end
    end
  end

  # increment current y by amount of eras
  # plus the 4 lines around the current era
  # plus the 1 line of browser names
  # plus the 2 blank lines above and below the eras
  cy += (relevant_era_count - 1) * (compact_height ? 3 : 4) + relevant_era_count + (relevant_era_count % 2 == 0 ? 0 : 1)

  if height > cy + 3
    # print legend header
    Curses.setpos offset_y + cy, offset_x
    Curses.attron color(:header) do
      Curses.addstr legend_format
    end
  end

  # increment current y by 2
  # one for the header line
  # plus one for a blank line below it
  cy += 2

  # loop through all features to create a legend
  # showing which label belongs to which color
  if height > cy + 1
    Feature::TYPES.values.each_slice viewable do |group|
      # draw legend texts at proper position
      group.compact.each.with_index do |type, lx|
        Curses.setpos offset_y + cy, offset_x + lx * col_width + lx
        Curses.attron color(type[:name], :fg) do
          Curses.addstr "#{type[:short]}(#{type[:symbol]})".center(col_width)
        end
      end

      # if there is more than one group, print the next
      # group on a new line
      cy += 1
    end
  end

  # add extra empty line after legend
  cy += 1

  notes_chunked = feature.notes.map { |nt| nt.chars.each_slice(outer_width).map(&:join).map(&:strip) }
  filter_vis    = Cani.config.notes == 'relevant' ? notes_visible.map(&:to_s) : feature.notes_by_num.keys

  num_chunked   = feature.notes_by_num
                         .select { |(k, _)| filter_vis.include? k }
                         .each_with_object({}) { |(k, nt), h| h[k] = nt.chars.each_slice(outer_width - 5).map(&:join).map(&:strip) }
  notes_total   = (notes_chunked.map(&:size) + num_chunked.map(&:size)).reduce(0) { |total, add| total + add }

  if height > cy + 2 && (notes_chunked.any? || num_chunked.any?)
    # print notes header
    Curses.setpos offset_y + cy, offset_x
    Curses.attron color(:header) do
      Curses.addstr notes_format
    end
  end

  # add two new lines, one for the notes header
  # and one empty line below it
  cy += 2

  # print global notes, wrapped on terminal width
  notes_chunked.each do |chunks|
    break if cy + 1 + chunks.size > height

    chunks.each do |part|
      Curses.setpos offset_y + cy, offset_x
      Curses.addstr part
      cy += 1
    end

    cy += 1
  end

  # print numbered notes, wrapped on terminal width
  num_chunked.each do |num, chunks|
    break if cy + 1 + chunks.size > height

    Curses.setpos offset_y + cy, offset_x
    Curses.attron color(:header) do
      Curses.addstr num.center(3)
    end

    chunks.each do |part|
      Curses.setpos offset_y + cy, offset_x + 5
      Curses.addstr part
      cy += 1
    end

    cy += 1
  end

  Curses.refresh
end
note_color(status) click to toggle source
# File lib/cani/api/feature/viewer.rb, line 420
def note_color(status)
  color status, :fg, NOTE_COLORS
end
percent_color(percent) click to toggle source
# File lib/cani/api/feature/viewer.rb, line 428
def percent_color(percent)
  PERCENT_COLORS.find { |(r, _)| r.include? percent }.to_a
                .fetch(1, {})
                .fetch(:fg, COLORS[:unknown][:fg])
end
render() click to toggle source
# File lib/cani/api/feature/viewer.rb, line 372
def render
  loop do
    Curses.clear
    draw

    key = Curses.getch
    case key
    when Curses::KEY_RESIZE then resize
    else break unless key.nil?
    end
  end

  close
end
resize() click to toggle source
# File lib/cani/api/feature/viewer.rb, line 397
def resize
  @height, @width = IO.console.winsize
  @viewable       = browsers.size

  @viewable -= 1 while tablew >= @width

  @col_width   = [colw, Feature::TYPES.map { |(_, h)| h[:short].size }.max + 3].max
  @table_width = tablew - 2 # vertical padding at start and end of current era line
end
status_color(status) click to toggle source
# File lib/cani/api/feature/viewer.rb, line 424
def status_color(status)
  color status, :fg
end
tablew() click to toggle source
# File lib/cani/api/feature/viewer.rb, line 393
def tablew
  colw * viewable + viewable - 1
end