Class: CursesMenu

Inherits:
Object
  • Object
show all
Defined in:
lib/curses_menu.rb,
lib/curses_menu/version.rb,
lib/curses_menu/curses_row.rb

Overview

Provide a menu using curses with keys navigation and selection

Defined Under Namespace

Classes: CursesRow

Constant Summary collapse

COLORS_TITLE =

Define some color pairs names. The integer value is meaningless in itself but they all have to be different.

1
COLORS_LINE =
2
COLORS_MENU_ITEM =
3
COLORS_MENU_ITEM_SELECTED =
4
COLORS_INPUT =
5
COLORS_GREEN =
6
COLORS_RED =
7
COLORS_YELLOW =
8
COLORS_BLUE =
9
COLORS_WHITE =
10
KEY_ENTER =

curses keys that are not defined by Curses, but that are returned by getch

10
KEY_ESCAPE =
27
VERSION =
'0.2.0'

Instance Method Summary collapse

Constructor Details

#initialize(title, key_presses: [], &menu_items_def) ⇒ CursesMenu

Constructor. Display a list of choices, ask for user input and execute the choice made. Repeat the operation unless one of the code returns the :menu_exit symbol.

Parameters
  • title (String): Title of those choices

  • key_presses (Array<Object>): List of key presses to automatically apply [default: []] Can be characters or ascii values, as returned by curses’ getch. The list is modified in place along with its consumption, so that it can be reused in sub-menus if needed.

  • *&menu_items_def* (Proc): Code to be called to get the list of choices. This code can call the following methods to design the menu:

    • Parameters
      • menu (CursesMenu): The CursesMenu instance



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/curses_menu.rb', line 36

def initialize(title, key_presses: [], &menu_items_def)
  @current_menu_items = nil
  @curses_initialized = false
  current_items = gather_menu_items(&menu_items_def)
  selected_idx = 0
  raise "Menu #{title} has no items to select" if selected_idx.nil?

  window = curses_menu_initialize
  begin
    max_displayed_items = window.maxy - 5
    display_first_idx = 0
    display_first_char_idx = 0
    loop do
      # TODO: Don't redraw fixed items for performance
      # Display the title
      window.setpos(0, 0)
      print(window, '', default_color_pair: COLORS_TITLE, pad: '=')
      print(window, "= #{title}", default_color_pair: COLORS_TITLE, pad: ' ', single_line: true)
      print(window, '', default_color_pair: COLORS_TITLE, pad: '-')
      # Display the menu
      current_items[display_first_idx..display_first_idx + max_displayed_items - 1].each.with_index do |item_info, idx|
        selected = display_first_idx + idx == selected_idx
        # Keep a cache of titles as they can be loaded in a lazy way for performance
        item_info[:title_cached] = item_info[:title].is_a?(Proc) ? item_info[:title].call : item_info[:title] unless item_info.key?(:title_cached)
        print(
          window,
          item_info[:title_cached],
          from: display_first_char_idx,
          default_color_pair: item_info.key?(:actions) ? COLORS_MENU_ITEM : COLORS_LINE,
          force_color_pair: selected ? COLORS_MENU_ITEM_SELECTED : nil,
          pad: selected ? ' ' : nil,
          single_line: true
        )
      end
      # Display the footer
      window.setpos(window.maxy - 2, 0)
      print(window, '', default_color_pair: COLORS_TITLE, pad: '=')
      display_actions = {
        'Arrows/Home/End' => 'Navigate',
        'Esc' => 'Exit'
      }
      # Keep a cache of actions as they can be loaded in a lazy way for performance
      current_items[selected_idx][:actions_cached] = current_items[selected_idx][:actions].is_a?(Proc) ? current_items[selected_idx][:actions].call : current_items[selected_idx][:actions] unless current_items[selected_idx].key?(:actions_cached)
      if current_items[selected_idx][:actions_cached]
        display_actions.merge!(
          current_items[selected_idx][:actions_cached].to_h do |action_shortcut, action_info|
            [
              case action_shortcut
              when KEY_ENTER
                'Enter'
              else
                action_shortcut
              end,
              action_info[:name]
            ]
          end
        )
      end
      print(
        window,
        "= #{display_actions.sort.map { |(shortcut, name)| "#{shortcut}: #{name}" }.join(' | ')}",
        from: display_first_char_idx,
        default_color_pair: COLORS_TITLE,
        pad: ' ',
        add_nl: false,
        single_line: true
      )
      window.refresh
      user_choice = nil
      loop do
        user_choice = key_presses.empty? ? window.getch : key_presses.shift
        break unless user_choice.nil?

        sleep 0.01
      end
      case user_choice
      when Curses::KEY_RIGHT
        display_first_char_idx += 1
      when Curses::KEY_LEFT
        display_first_char_idx -= 1
      when Curses::KEY_UP
        selected_idx -= 1
      when Curses::KEY_PPAGE
        selected_idx -= max_displayed_items - 1
      when Curses::KEY_DOWN
        selected_idx += 1
      when Curses::KEY_NPAGE
        selected_idx += max_displayed_items - 1
      when Curses::KEY_HOME
        selected_idx = 0
      when Curses::KEY_END
        selected_idx = current_items.size - 1
      when KEY_ESCAPE
        break
      else
        # Check actions
        # Keep a cache of actions as they can be loaded in a lazy way for performance
        current_items[selected_idx][:actions_cached] = current_items[selected_idx][:actions].is_a?(Proc) ? current_items[selected_idx][:actions].call : current_items[selected_idx][:actions] unless current_items[selected_idx].key?(:actions_cached)
        if current_items[selected_idx][:actions_cached]&.key?(user_choice)
          curses_menu_finalize
          result = current_items[selected_idx][:actions_cached][user_choice][:execute].call
          if result.is_a?(Symbol)
            case result
            when :menu_exit
              break
            when :menu_refresh
              current_items = gather_menu_items(&menu_items_def)
            end
          end
          window = curses_menu_initialize
          window.clear
        end
      end
      # Stay in bounds
      display_first_char_idx = 0 if display_first_char_idx.negative?
      selected_idx = current_items.size - 1 if selected_idx >= current_items.size
      selected_idx = 0 if selected_idx.negative?
      if selected_idx < display_first_idx
        display_first_idx = selected_idx
      elsif selected_idx >= display_first_idx + max_displayed_items
        display_first_idx = selected_idx - max_displayed_items + 1
      end
    end
  ensure
    curses_menu_finalize
  end
end

Instance Method Details

#item(title, actions: {}, &action) ⇒ Object

Register a new menu item. This method is meant to be called from a choose_from call.

Parameters
  • title (String, CursesRow or Proc): Text to be displayed for this item, or Proc returning this text when needed (lazy loading)

  • actions (Hash<Object, Hash<Symbol,Object> > or Proc): Associated actions to this item, per shortcut, or Proc returning those actions when needed (lazy loading) [default: {}]

    • name (String): Name of this action (displayed at the bottom of the menu)

    • execute (Proc): Code called when this action is selected

    In case of lazy loading (with a Proc), the title Proc will always be called first.

  • *&action* (Proc): Code called if the item is selected (action for the enter key) [optional].

    • Result
      • Symbol or Object: If the code returns a symbol, the menu will behave in a specific way:

        • menu_exit: the menu selection exits.

        • menu_refresh: The menu will compute again its items.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/curses_menu.rb', line 178

def item(title, actions: {}, &action)
  menu_item_def = { title: title }
  all_actions =
    if action.nil?
      actions
    else
      mapped_default_action = { KEY_ENTER => { name: 'Select', execute: action } }
      if actions.is_a?(Proc)
        # Make sure we keep the lazyness
        proc do
          actions.call.merge(mapped_default_action)
        end
      else
        actions.merge(mapped_default_action)
      end
    end
  menu_item_def[:actions] = all_actions if all_actions.is_a?(Proc) || !all_actions.empty?
  @current_menu_items << menu_item_def
end