├── .gitignore ├── LICENSE ├── README.md ├── diagrams.monopic ├── examples ├── complex_form.py ├── control_showcase.py └── hello_world.py ├── sailor.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rico Huijbers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sailor, yet another curses widget library 2 | ========================================= 3 | 4 | I didn't like npyscreen and Urwid, so I made an ncurses widget library. 5 | 6 | * npyscreen looks great, but I don't like the way it uses classes and 7 | object-oriented programming. It's very hard to make composite widgets and 8 | complex layout from simple building blocks (i.e., without having to implement 9 | a bunch of custom classes) 10 | 11 | * Urwid looks gaudy, and more low-level than what I want. 12 | 13 | Sailor fills the gap that I saw in this space. 14 | 15 | For users 16 | --------- 17 | 18 | One control is the root of your GUI application. It is continuously rendered 19 | until the application exits. 20 | 21 | Some controls, such as `Button`s, have event handers. The argument to an event 22 | handler always includes a built-in `app` object. You exit the GUI loop by 23 | setting `app.exit = True`. 24 | 25 | Start the GUI loop by calling `sailor.walk(root_control)`. 26 | 27 | Example: 28 | 29 | ```python 30 | def do_exit(app): 31 | app.exit = True 32 | 33 | root = s.Panel( 34 | caption=s.Text('Hello World'), 35 | controls=[ 36 | s.Text('This just shows you some text in a bordered panel.'), 37 | s.Text('Hit the button below to quit.'), 38 | s.Button('Exit', on_click=do_exit) 39 | ]) 40 | 41 | s.walk(root) 42 | ``` 43 | 44 | Since there is only ever one control that is the root control, to do interesting 45 | things you need to make this top-level control either a control that contains 46 | multiple other controls (`Panel`), a control that contains a single other 47 | control but which one that is can change (`SwitchableControl`), or generate 48 | `Popup` controls in response to events. 49 | 50 | ### Available controls 51 | 52 | * `Text(string, [fg], [bg])`: display some literal text. 53 | * `Edit(string, [min_size], [highlight])`: text edit control. `highlight` 54 | can be a function to syntax highlight the entered text. See the source 55 | for info :) 56 | * `AutoCompleteEdit(string, complete_fn, [min_size])`: like edit, but 57 | has a funciton that takes the current word and returns all possible 58 | completions. 59 | * `Button(string, [on_click])`: A bog-standard button. 60 | * `Panel(controls, [caption], [underscript])`: vertically contains other 61 | controls, surrounded by a box. 62 | * `Labeled(string, control)`: puts a label to the left of the control. 63 | * `SelectList(options, [index], [width], [height])`: shows a selection list. 64 | The selected value is available in `.value`. 65 | * `Combo(options, [index])`: a SelectList in a popup. 66 | * `SelectDate(value)`: shows a day calendar. `.value` is in datetime format, 67 | `.date` in date. 68 | * `Popup(control, on_close).show(app)`: show a modal popup that contains another 69 | control. The popup is automatically removed when an ENTER or ESC keypress 70 | escapes the focused control, but `on_close(popup, app)` will only be called if 71 | ENTER was used to remove the popup. 72 | * `Time(value)`: a time selection control. `.time` has the selected time. 73 | * `Stacked(controls)`: vertically contains other controls, no decoration. 74 | * `PreviewPane(text, [row_selectable], [on_select_row])`: a scrollable panel 75 | to display a large document in. 76 | * `SwitchableControl(initial_control)`: control that can switch what 77 | control it's displaying. 78 | 79 | Impression: 80 | 81 | ``` 82 | ┌──Control showcase───────────────────────────────────────────────────────────┐ 83 | │ Text This is plain text in a different color │ 84 | │ Panel ┌─────────────────────────────────────────────────────────┐ │ 85 | │ │ Inner panel just because we can │ │ 86 | │ └─────────────────────────────────────────────────────────┘ │ 87 | │ SelectList option 3 │ 88 | │ option 4 │ 89 | │ option 5 │ 90 | │ Combo option 1 │ 91 | │ DateCombo July 11, 2018 │ 92 | │ Time 19:15 UTC │ 93 | │ Popup [ Hit me ] │ 94 | │ Edit you can edit this │ 95 | │ AutoComplete type here │ 96 | │ SwitchableCtrl Switchable 1 │ 97 | │ [ Next ] │ 98 | │ Button [ ┌────────────────────────────────────┐ │ 99 | └───────────────────│ Just a popup to show you something │────────────────────┘ 100 | └────────────────────────────────────┘ 101 | ``` 102 | 103 | For control authors 104 | ------------------- 105 | 106 | As a user of `sailor`, you might want to build your own higher-level 107 | controls out of lower-level building blocks. This section might also 108 | be interesting to you. 109 | 110 | ``` 111 | can contain 112 | multiple 113 | ┌───────────────┐ ┌───┐ 114 | │ │ │ │ 115 | │ ▼ │ ▼ 116 | │ continuously ┌────┴────────┐ ┌──────────────┐ 117 | │ render root │ │ renders to │ │ 118 | │ control │ Control ├─────────────▶│ View │ 119 | │ ─────────────────▶│ │ │ │ 120 | │ │ └─────────────┘ └──────────────┘ 121 | │ │ manages state renders to screen 122 | └───────────────┘ 123 | 124 | sailor.walk(control) EXAMPLES EXAMPLES 125 | 126 | Button Display 127 | Edit Box 128 | Combo Centered 129 | Panel HFill 130 | Date ... 131 | Time 132 | ``` 133 | 134 | Sailor consists of `Control`s. Some controls have _values_ (such as a text edit 135 | field). Other controls (such as a `Panel`) only exist to contain other controls 136 | and lay them out in a certain way. 137 | 138 | Controls have a way to render themselves. The result of rendering a control is 139 | a `View`. No painting has been done at this point, a view is just another 140 | object. Finally, a view is asked to render itself to an ncurses screen, which 141 | can entail painting characters, rendering subviews, or both. Examples of view 142 | objects are horizontal or vertical layouts, a piece of text, or a box. 143 | 144 | The advantage of separating `Control`s and `View`s is that the control doesn't 145 | have to bother with the low-level details of painting. It deals with state 146 | management and can generally just return a convenient display representation of 147 | itself using the `View` primitives, which will automatically lay themselves 148 | out in convenient way. 149 | 150 | The overarching philosophy is that in sailor, you work at the object-level, 151 | piecing together objects to do what you want, as opposed to inheriting and 152 | overriding classes. It works very much in "immediate mode", like React, where 153 | all controls paint themselves to the screen on every frame, and the framework 154 | makes sure that updates are done efficiently. 155 | 156 | We don't use any of the facilities of ncurses like windows and pads. These 157 | serve a similar purpose to what sailor does by itself, but more dynamically 158 | (controls can easily resize itself in sailor). Efficiently updating the 159 | terminal by doing a diff of two screen states is the purpose of the standard 160 | curses library, so sailor doesn't need to take care to be efficient. 161 | 162 | ### Controls 163 | 164 | We do have _some_ inheritance. Controls inherit from `Control`. Controls are 165 | supposed to do the following things: 166 | 167 | * Set `self.can_focus` if the control can receive focus. 168 | * Implement `children()` if the control has subcontrols. 169 | * Implement `render(app)` to return the view of the control. 170 | * Implement `on_event(event)` to handle events. 171 | 172 | There are a bunch of default controls already: 173 | 174 | * `Text` 175 | * `Edit` 176 | * `Panel` 177 | * `Labeled` 178 | * `SelectList` 179 | * `Combo` 180 | * `Composite` 181 | * `Popup` 182 | * `SelectDate` 183 | * `DateCombo` 184 | 185 | ### Views 186 | 187 | Views are used to render characters to the screen or laying out other views. 188 | You should rarely need to implement new View classes, but if you want to, a 189 | View needs to: 190 | 191 | * Implement `size(parent_rect)`, returning the size needed for the view given 192 | the rect to work in. 193 | * Implement `disp(parent_rect)`, render (using ncurses routines) in the given 194 | rect (same as passed to `size()`). 195 | 196 | Available Views are: 197 | 198 | * `Display` 199 | * `HFill` 200 | * `Horizontal`, `Vertical` 201 | * `Grid` 202 | * `Box` 203 | * `FloatingWindow` 204 | -------------------------------------------------------------------------------- /diagrams.monopic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rix0rrr/sailor/e92a6255387541b907b878666f9ccac139a56318/diagrams.monopic -------------------------------------------------------------------------------- /examples/complex_form.py: -------------------------------------------------------------------------------- 1 | """Sample sailor program.""" 2 | # Load sailor from one directory higher 3 | import sys 4 | sys.path.insert(0, '..') 5 | 6 | import datetime 7 | import sailor as s 8 | 9 | 10 | now = datetime.datetime.now() 11 | now_h = now.strftime('%H') 12 | now_m = '%02d' % ((int(now.strftime('%M')) / 5) * 5) 13 | 14 | hours = ['%02d' % h for h in range(0, 24)] 15 | minutes = ['%02d' % m for m in range(0, 60, 5)] 16 | 17 | 18 | def do_exit(app): 19 | app.exit = True 20 | 21 | 22 | panel = s.Panel(caption=s.Text('sailor is awesome'), controls=[ 23 | s.Labeled('Edit', s.Edit('Type here', id='edit')), 24 | s.Labeled('Combo', s.Combo(['Choice 1', 'Choice 2', 'Choice 3'], id='choice')), 25 | s.Labeled('Date', s.DateCombo(id='date')), 26 | s.Labeled('Time', s.Composite([ 27 | s.Combo(choices=hours, index=hours.index(now_h), id='hour'), 28 | s.Text(':'), 29 | s.Combo(choices=minutes, index=minutes.index(now_m), id='min')])), 30 | s.Labeled('Where', s.Composite([ 31 | s.Edit('Candles', id='field'), 32 | s.Combo(['>=', '<=', '==', 'eq', 'ne'], id='op'), 33 | s.Edit('500', id='val'), 34 | ], margin=1)), 35 | s.Labeled('', s.Button('Exit', on_click=do_exit)), 36 | ]) 37 | 38 | s.walk(panel) 39 | 40 | print('Date/Time: %s %s:%s' % (panel.find('date').value.date(), panel.find('hour').value, panel.find('min').value)) 41 | print('Combo: %s' % panel.find('choice').value) 42 | print('Edit: %s' % panel.find('edit').value) 43 | print('Field: %s' % panel.find('field').value) 44 | print('Operation: %s' % panel.find('op').value) 45 | print('Value: %s' % panel.find('val').value) 46 | -------------------------------------------------------------------------------- /examples/control_showcase.py: -------------------------------------------------------------------------------- 1 | """Sample sailor program.""" 2 | # Load sailor from one directory higher 3 | import sys 4 | sys.path.insert(0, '..') 5 | 6 | import sailor as s 7 | 8 | def do_exit(app): 9 | app.exit = True 10 | 11 | options = [ 12 | 'option 1', 13 | 'option 2', 14 | s.Option('three is special', 'option 3'), 15 | 'option 4', 16 | 'option 5', 17 | ] 18 | 19 | 20 | def show_popup(app): 21 | def do_nothing(popup, app): 22 | # Don't want to do anything here 23 | pass 24 | 25 | popup = s.Popup(s.Stacked([ 26 | s.Text('Just a popup to show you something') 27 | ]), on_close=do_nothing) 28 | popup.show(app) 29 | 30 | 31 | def complete_fn(word): 32 | candidates = ['foo', 'bar', 'baz', 'sailor', 'curses', 'walk', 'hello', 'world'] 33 | return [w for w in candidates if w.startswith(word)] 34 | 35 | def main(): 36 | SWITCHABLES = [ 37 | s.Text('Switchable 1'), 38 | s.Button("Switchable 2"), 39 | s.Panel([s.Text('Bigger than you thought')], caption=s.Text('Switchable 3')) 40 | ] 41 | current_switchable = [0] # Reference hack 42 | switchable = s.SwitchableControl(SWITCHABLES[current_switchable[0]]) 43 | 44 | def do_next(app): 45 | current_switchable[0] = (current_switchable[0] + 1) % len(SWITCHABLES) 46 | switchable.switch(SWITCHABLES[current_switchable[0]], app) 47 | 48 | root = s.Panel( 49 | caption=s.Text('Control showcase'), 50 | controls=[ 51 | s.Labeled('Text', s.Composite([ 52 | s.Text('This is plain text'), 53 | s.Text('in a different color', fg=s.red), 54 | ], margin=1)), 55 | s.Labeled('Panel', s.Panel([s.Text('Inner panel just because we can')])), 56 | s.Labeled('SelectList', s.SelectList(options, 0, height=3)), 57 | s.Labeled('Combo', s.Combo(options)), 58 | s.Labeled('DateCombo', s.DateCombo()), 59 | s.Labeled('Time', s.Time()), 60 | s.Labeled('Popup', s.Button('Hit me', on_click=show_popup)), 61 | s.Labeled('Edit', s.Edit('you can edit this')), 62 | s.Labeled('AutoComplete', s.AutoCompleteEdit('type here', complete_fn)), 63 | s.Labeled('SwitchableCtrl', switchable), 64 | s.Labeled('', s.Button('Next', on_click=do_next)), 65 | s.Labeled('Button', s.Button('Exit', on_click=do_exit)), 66 | ]) 67 | 68 | s.walk(root) 69 | 70 | if __name__ == '__main__': 71 | main() 72 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | """Sample sailor program.""" 2 | # Load sailor from one directory higher 3 | import sys 4 | sys.path.insert(0, '..') 5 | 6 | import sailor as s 7 | 8 | def do_exit(app): 9 | app.exit = True 10 | 11 | root = s.Panel( 12 | caption=s.Text('Hello World'), 13 | controls=[ 14 | s.Text('This just shows you some text in a bordered panel.'), 15 | s.Text('Hit the button below to quit.'), 16 | s.Button('Exit', on_click=do_exit) 17 | ]) 18 | 19 | s.walk(root) 20 | -------------------------------------------------------------------------------- /sailor.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import curses 3 | import curses.ascii 4 | from curses import textpad 5 | import datetime 6 | import itertools 7 | import logging 8 | import os 9 | import string 10 | 11 | logger = logging.getLogger('sailor') 12 | 13 | CTRL_A = 1 14 | CTRL_E = ord('e') - ord('a') + 1 15 | CTRL_J = ord('j') - ord('a') + 1 16 | CTRL_K = ord('k') - ord('a') + 1 17 | CTRL_N = ord('n') - ord('a') + 1 18 | CTRL_P = ord('p') - ord('a') + 1 19 | CTRL_U = ord('u') - ord('a') + 1 20 | 21 | MAC_BACKSPACE = 127 # Don't feel like finding out why 22 | SHIFT_TAB = 353 23 | CR = 13 # Or Ctrl-M, so don't use that 24 | OTHER_DEL = 330 25 | 26 | black = curses.COLOR_BLACK 27 | red = curses.COLOR_RED 28 | green = curses.COLOR_GREEN 29 | white = curses.COLOR_WHITE 30 | blue = curses.COLOR_BLUE 31 | cyan = curses.COLOR_CYAN 32 | magenta = curses.COLOR_MAGENTA 33 | yellow = curses.COLOR_YELLOW 34 | 35 | # FIXME: Crash when running off the edges 36 | 37 | def reduce_esc_delay(): 38 | try: 39 | os.environ['ESCDELAY'] 40 | except KeyError: 41 | os.environ['ESCDELAY'] = '25' 42 | 43 | 44 | def is_enter(ev): 45 | return ev.key in [curses.KEY_ENTER, CR] 46 | 47 | 48 | def ident(x): 49 | return x 50 | 51 | 52 | def get_value(x): 53 | return x.value if isinstance(x, Option) else x 54 | 55 | 56 | def flatten(listOfLists): 57 | return itertools.chain.from_iterable(listOfLists) 58 | 59 | 60 | class Colorized(object): 61 | def __init__(self, text, color, attr=0): 62 | self.text = text 63 | self.color = color 64 | self.attr = attr 65 | 66 | def __str__(self): 67 | return '\0' + str(self.color) + '\1' + str(self.attr) + '\1' + str(self.text) + '\0' 68 | 69 | 70 | def handle_scroll_key(key, current_row, row_count, scroll_offset, win_height, page_size=10): 71 | """Handle scrolling one or more lines based on key presses. 72 | 73 | Returns: 74 | (change, row_index, scroll_offset) tuple 75 | """ 76 | v_scrolls = { 77 | curses.KEY_UP: -1, 78 | ord('k'): -1, 79 | curses.KEY_PPAGE: -page_size, 80 | ord('K'): -page_size, 81 | curses.KEY_DOWN: 1, 82 | ord('j'): 1, 83 | curses.KEY_NPAGE: page_size, 84 | ord('J'): page_size, 85 | curses.KEY_HOME: -9999999999, 86 | ord('g'): -9999999999, 87 | curses.KEY_END: 9999999999, 88 | ord('G'): 9999999999, 89 | } 90 | 91 | if key not in v_scrolls: 92 | return False, current_row, scroll_offset 93 | 94 | new_row = max(0, min(current_row + v_scrolls[key], row_count - 1)) 95 | if current_row == new_row: 96 | return False, current_row, scroll_offset 97 | 98 | scroll_offset = min(scroll_offset, new_row) 99 | scroll_offset = max(scroll_offset, new_row - win_height + 1) 100 | return True, new_row, scroll_offset 101 | 102 | 103 | #---------------------------------------------------------------------- 104 | # VIEW classes 105 | 106 | class View(object): 107 | """Base class for objects that can paint themselves to a curses surface. 108 | 109 | Sailor users don't instantiate views. Instead, they instantiate controls, 110 | which render themselves Views to represent their current physical appearance. 111 | """ 112 | def size(self, rect): 113 | """Return the size that the view takes up.""" 114 | return (0, 0) 115 | 116 | def display(self, rect): 117 | """Render the view inside the given rectangle.""" 118 | self.rect = rect 119 | if rect.w > 0 and rect.h > 0: 120 | self.disp(rect) 121 | 122 | def disp(self, rect): 123 | """Overridden by subclasses to do the actual rendering.""" 124 | raise RuntimeError('Not implemented: disp()') 125 | 126 | 127 | class Display(View): 128 | """A view that displays literal characters.""" 129 | def __init__(self, text, min_width=0, fg=white, bg=black, attr=0): 130 | if isinstance(text, list): 131 | self.lines = text 132 | else: 133 | self.lines = str(text).split('\n') 134 | self.fg = fg 135 | self.bg = bg 136 | self.min_width = min_width 137 | self.attr = attr 138 | 139 | @property 140 | def text(self): 141 | return '\n'.join(self.lines) 142 | 143 | def size(self, rect): 144 | return max(self.min_width, max(len(l) for l in self.lines)), len(self.lines) 145 | 146 | def disp(self, rect): 147 | col = rect.get_color(self.fg, self.bg) 148 | print_width = max(0, rect.w) 149 | lines = self.lines[:rect.h] 150 | if print_width > 0 and lines: 151 | for i, line in enumerate(lines): 152 | padding = ' ' * min(print_width, self.min_width - len(line)) 153 | try: 154 | rect.screen.addstr(rect.y + i, rect.x, line[:print_width] + padding, curses.color_pair(col) | self.attr) 155 | except curses.error, e: 156 | logger.warn(str(e)) 157 | 158 | 159 | class Positioned(View): 160 | """A view that offsets another view inside the given rectangle.""" 161 | def __init__(self, inner, x=-1, y=-1): 162 | self.inner = inner 163 | self.x = x 164 | self.y = y 165 | 166 | def size(self, rect): 167 | return self.inner.size(rect) 168 | 169 | def disp(self, rect): 170 | size = self.size(rect) 171 | 172 | x = max(0, min(self.x, rect.w - size[0])) 173 | irect = Rect(rect.app, rect.screen, x, self.y, size[0], size[1]) 174 | irect.clear() 175 | self.inner.display(irect) 176 | 177 | 178 | class Centered(View): 179 | """A view that centers another view inside the available rectangle.""" 180 | def __init__(self, inner): 181 | self.inner = inner 182 | 183 | def size(self, rect): 184 | return rect 185 | 186 | def disp(self, rect): 187 | size = self.inner.size(rect) 188 | x = (rect.w - size[0]) / 2 189 | y = (rect.h - size[1]) / 2 190 | irect = rect.sub_rect(x, y, size[0], size[1]) 191 | self.inner.display(irect) 192 | 193 | 194 | class AlignRight(View): 195 | """A view that right-aligns another view inside the available rectangle.""" 196 | def __init__(self, inner, h_margin=2, v_margin=1): 197 | self.inner = inner 198 | self.h_margin = h_margin 199 | self.v_margin = v_margin 200 | 201 | def size(self, rect): 202 | return rect.w, rect.h 203 | 204 | def disp(self, rect): 205 | w, h = self.inner.size(rect) 206 | irect = rect.adj_rect(rect.w - w - self.h_margin, self.v_margin) 207 | self.inner.display(irect) 208 | 209 | 210 | class HFill(View): 211 | """A view that uses a single character to fill out the available width.""" 212 | def __init__(self, char, fg=white, bg=black): 213 | self.char = char 214 | self.fg = fg 215 | self.bg = bg 216 | 217 | def size(self, rect): 218 | return rect.w, 1 219 | 220 | def disp(self, rect): 221 | col = rect.get_color(self.fg, self.bg) 222 | rect.screen.addstr(rect.y, rect.x, self.char * rect.w, curses.color_pair(col)) 223 | 224 | 225 | class Horizontal(View): 226 | """A view that lays out other views horizontally.""" 227 | def __init__(self, views, margin=0): 228 | assert(all(views)) 229 | self.views = views 230 | self.margin = margin 231 | 232 | def size(self, rect): 233 | sizes = [] 234 | for v in self.views: 235 | sizes.append(v.size(rect)) 236 | rect = rect.adj_rect(sizes[-1][0], 0) 237 | 238 | widths = [s[0] for s in sizes] 239 | heights = [s[1] for s in sizes] 240 | return sum(widths) + max(len(self.views) - 1, 0) * self.margin, max(heights + [0]) 241 | 242 | def disp(self, rect): 243 | for v in self.views: 244 | v.display(rect) 245 | dx = v.size(rect)[0] + self.margin 246 | rect = rect.adj_rect(dx, 0) 247 | 248 | 249 | class Grid(View): 250 | """A view that lays out other views in a grid.""" 251 | def __init__(self, grid, h_margin=1, align_right=False): 252 | self.grid = grid 253 | self.h_margin = h_margin 254 | self.align_right = align_right 255 | 256 | def size(self, rect): 257 | # FIXME: Not correct for size-adapting controls 258 | self.size_grid = [[col.size(rect) for col in row] 259 | for row in self.grid] 260 | cols = len(self.size_grid[0]) 261 | self.col_widths = [max(self.size_grid[i][col_nr][0] for i in range(len(self.size_grid))) 262 | for col_nr in range(cols)] 263 | self.row_heights = [max(col[1] for col in row) 264 | for row in self.size_grid] 265 | w = sum(self.col_widths) + (len(self.col_widths) - 1) * self.h_margin 266 | h = sum(self.row_heights) 267 | return w, h 268 | 269 | def disp(self, rect): 270 | for j, row in enumerate(self.grid): 271 | rrect = rect.adj_rect(0, sum(self.row_heights[:j])) 272 | for i, cell in enumerate(row): 273 | col_width = self.col_widths[i] 274 | cell_size = cell.size(rect) 275 | if self.align_right: 276 | rrect = rrect.adj_rect(col_width - cell_size[0], 0) 277 | cell.display(rrect) 278 | rrect = rrect.adj_rect(cell_size[0] + self.h_margin, 0) 279 | 280 | 281 | class Vertical(View): 282 | """A view that lays out other views vertically.""" 283 | def __init__(self, views, margin=0): 284 | self.views = views 285 | self.margin = margin 286 | 287 | def size(self, rect): 288 | sizes = [] 289 | for v in self.views: 290 | sizes.append(v.size(rect)) 291 | rect = rect.adj_rect(0, sizes[-1][1]) 292 | 293 | widths = [s[0] for s in sizes] 294 | heights = [s[1] for s in sizes] 295 | return max(widths + [0]), sum(heights) + max(len(self.views) - 1, 0) * self.margin 296 | 297 | def disp(self, rect): 298 | for v in self.views: 299 | v.display(rect) 300 | dy = v.size(rect)[1] + self.margin 301 | rect = rect.adj_rect(0, dy) 302 | 303 | 304 | class Box(View): 305 | """A box with another view inside it.""" 306 | def __init__(self, inner, caption=None, underscript=None, x_margin=1, y_margin=0, x_fill=True, y_fill=False): 307 | self.inner = inner 308 | self.caption = caption 309 | self.underscript = underscript 310 | self.x_margin = x_margin 311 | self.y_margin = y_margin 312 | self.x_fill = x_fill 313 | self.y_fill = y_fill 314 | 315 | def size(self, rect): 316 | if not self.x_fill or not self.y_fill: 317 | inner_size = self.inner.size(rect.adj_rect(1 + self.x_margin, 1 + self.y_margin, 1 + self.x_margin, 1 + self.y_margin)) 318 | w = rect.w if self.x_fill else inner_size[0] + 2 * (1 + self.x_margin) 319 | h = rect.h if self.y_fill else inner_size[1] + 2 * (1 + self.y_margin) 320 | return w, h 321 | 322 | def disp(self, rect): 323 | size = self.size(rect) 324 | 325 | rect_w = min(size[0], rect.w) 326 | rect_h = min(size[1], rect.h) 327 | 328 | if rect_w > 0 and rect_h > 0: 329 | x1 = rect.x + rect_w - 1 330 | y1 = rect.y + rect_h - 1 331 | 332 | # Make sure that we don't draw to the complete end of the screen, because that'll break 333 | screen_h = rect.screen.getmaxyx()[0] 334 | y1 = min(y1, screen_h - 2) 335 | 336 | try: 337 | rect.resize(rect_w, rect_h).clear() 338 | textpad.rectangle(rect.screen, rect.y, rect.x, y1, x1) 339 | if self.caption: 340 | self.caption.display(rect.adj_rect(3, 0)) 341 | if self.underscript: 342 | s = self.underscript.size(rect) 343 | self.underscript.display(rect.adj_rect(max(3, rect_w - s[0] - 3), rect_h - 1)) 344 | except curses.error, e: 345 | # We should not have sent this invalid draw command... 346 | logger.warn(e) 347 | try: 348 | self.inner.display(rect.adj_rect(1 + self.x_margin, 1 + self.y_margin, 1 + self.x_margin, 1 + self.y_margin)) 349 | except curses.error, e: 350 | # We should not have sent this invalid draw command... 351 | logger.warn(e) 352 | 353 | 354 | #---------------------------------------------------------------------- 355 | # CONTROL classes 356 | 357 | 358 | class Control(object): 359 | """Base class for Controls. 360 | 361 | New Control implementations should: 362 | 363 | - Pass on constructor **kwargs. 364 | - Put their children in the self.controls member, or override 365 | the children() method. 366 | - Override render() to return an instance of View. 367 | """ 368 | def __init__(self, fg=white, bg=black, id=None): 369 | self.fg = fg 370 | self.bg = bg 371 | self.id = id 372 | self.can_focus = False 373 | self.controls = [] 374 | 375 | def render(self, app): 376 | raise RuntimeError('Not implemented: render()') 377 | 378 | def children(self): 379 | return self.controls 380 | 381 | def on_event(self, ev): 382 | pass 383 | 384 | def contains(self, ctrl): 385 | if ctrl is self: 386 | return True 387 | 388 | for child in self.controls: 389 | if child.contains(ctrl): 390 | return True 391 | 392 | return False 393 | 394 | def find(self, id): 395 | for parent, child in object_tree(self): 396 | if child.id == id: 397 | return child 398 | raise RuntimeError('No such control: %s' % id) 399 | 400 | def _focus_order(self, key): 401 | return (reversed 402 | if key in [curses.KEY_UP, curses.KEY_LEFT, SHIFT_TAB] else 403 | ident) 404 | 405 | def enter_focus(self, key, app): 406 | if self.can_focus: 407 | app.layer(self).focus(self) 408 | return True 409 | 410 | order = self._focus_order(key) 411 | 412 | for child in order(self.children()): 413 | if child.enter_focus(key, app): 414 | return True 415 | return False 416 | 417 | 418 | class Text(Control): 419 | """Display some text in the UI.""" 420 | def __init__(self, value, **kwargs): 421 | super(Text, self).__init__(**kwargs) 422 | self.value = value 423 | self.can_focus = False 424 | 425 | @property 426 | def text(self): 427 | return self.value 428 | 429 | def render(self, app): 430 | return Display(self.value, fg=self.fg, bg=self.bg) 431 | 432 | 433 | def propagate_focus(ev, controls, layer, keys_back, keys_fwd): 434 | """Propagate focus events forwards and backwards through a list of controls.""" 435 | if ev.type == 'key': 436 | if ev.key in keys_back + keys_fwd: 437 | back = ev.key in keys_back 438 | current = ev.app.find_ancestor(ev.last, controls) 439 | if not current: 440 | return False 441 | 442 | i = controls.index(current) 443 | while 0 <= i < len(controls) and ev.propagating: 444 | i += -1 if back else 1 445 | if 0 <= i < len(controls) and controls[i].enter_focus(ev.key, ev.app): 446 | ev.stop() 447 | return True 448 | return False 449 | 450 | 451 | class Panel(Control): 452 | """Contains other controls vertically, surrounds them with a box.""" 453 | def __init__(self, controls, caption=None, underscript=None, **kwargs): 454 | super(Panel, self).__init__(**kwargs) 455 | self.controls = controls 456 | self.caption = caption 457 | self.underscript = None 458 | 459 | def render(self, app): 460 | return Box(Vertical([c.render(app) for c in self.controls]), 461 | caption=self.caption.render(app) if self.caption else None, 462 | underscript=self.underscript.render(app) if self.underscript else None) 463 | 464 | def on_event(self, ev): 465 | propagate_focus(ev, self.controls, ev.app.layer(self), 466 | [curses.KEY_UP, SHIFT_TAB], 467 | [curses.KEY_DOWN, curses.ascii.TAB]) 468 | 469 | 470 | class Stacked(Control): 471 | """Just lays out other controls vertically, no decoration.""" 472 | def __init__(self, controls, **kwargs): 473 | super(Stacked, self).__init__(**kwargs) 474 | self.controls = controls 475 | 476 | def render(self, app): 477 | return Vertical([c.render(app) for c in self.controls]) 478 | 479 | def on_event(self, ev): 480 | propagate_focus(ev, self.controls, ev.app.layer(self), 481 | [curses.KEY_UP, SHIFT_TAB], 482 | [curses.KEY_DOWN, curses.ascii.TAB]) 483 | 484 | 485 | class Option(object): 486 | """Helper class to attach data to a string. 487 | 488 | This object contains one value, but stringifies to a user-chosen 489 | string, making it ideal for separating symbolic values from human 490 | representation in selection lists/comboboxes. 491 | """ 492 | def __init__(self, value, caption=None): 493 | self.value = value 494 | self.caption = caption or str(value) 495 | 496 | def __str__(self): 497 | return self.caption 498 | 499 | def __eq__(self, other): 500 | if not isinstance(other, Option): 501 | return self.value == other 502 | return self.value == other.value 503 | 504 | def __ne__(self, other): 505 | return not self.__eq__(other) 506 | 507 | def __hash__(self): 508 | return hash(self.value) 509 | 510 | def __str__(self): 511 | return self.caption 512 | 513 | def __repr__(self): 514 | return 'Option(%r, %r)' % (self.value, self.caption) 515 | 516 | 517 | class Labeled(Control): 518 | """Applies an offset to a control, fill it with a text label.""" 519 | def __init__(self, label, control, **kwargs): 520 | super(Labeled, self).__init__(**kwargs) 521 | assert(control) 522 | self.label = label 523 | self.control = control 524 | 525 | def render(self, app): 526 | fg = white if app.contains_focus(self) else green 527 | attr = curses.A_BOLD if app.contains_focus(self) else 0 528 | return Horizontal([Display(self.label, min_width=16, fg=fg, attr=attr), 529 | self.control.render(app)]) 530 | 531 | def children(self): 532 | return [self.control] 533 | 534 | 535 | class SelectList(Control): 536 | """Selection list. 537 | 538 | Displays a set of values with an initial selected index, and lets the user 539 | change the selection. 540 | 541 | The `selectList.value` property contains the selected value. 542 | """ 543 | def __init__(self, choices, index=0, width=30, height=10, show_captions_at=0, **kwargs): 544 | super(SelectList, self).__init__(**kwargs) 545 | self.choices = choices 546 | self.index = index 547 | self.width = width 548 | self.height = height 549 | self.scroll_offset = max(0, min(self.index, len(self.choices) - height)) 550 | self.can_focus = True 551 | self.show_captions_at = show_captions_at 552 | 553 | def adjust(self, d): 554 | """Scroll by the given delta through the options.""" 555 | if len(self.choices) > 1: 556 | self.index = (self.index + d + len(self.choices)) % len(self.choices) 557 | self.scroll_offset = min(self.scroll_offset, self.index) 558 | self.scroll_offset = max(self.scroll_offset, self.index - self.height + 1) 559 | 560 | def sanitize_index(self): 561 | self.index = min(max(0, self.index), len(self.choices) - 1) 562 | return 0 <= self.index < len(self.choices) 563 | 564 | @property 565 | def value(self): 566 | """Return the currently selected value.""" 567 | return get_value(self.choices[self.index]) 568 | 569 | @value.setter 570 | def value(self, value): 571 | """Set the currently selected value. 572 | 573 | Reset to 0 if not in the list. 574 | """ 575 | self.index = max(0, self.choices.index(value)) 576 | 577 | def _render_line(self, line, selected): 578 | attr = curses.A_STANDOUT if selected else 0 579 | if self.show_captions_at and isinstance(line, Option): 580 | rem = self.width - self.show_captions_at 581 | return Horizontal([ 582 | Display(str(line.value)[:self.show_captions_at], min_width=self.show_captions_at, attr=attr), 583 | Display(str(line.caption)[:rem], min_width=rem, attr=attr, fg=cyan if not selected else white) 584 | ]) 585 | return Display(line, min_width=self.width, attr=attr) 586 | 587 | def render(self, app): 588 | self.sanitize_index() 589 | 590 | lines = self.choices[self.scroll_offset:self.scroll_offset + self.height] 591 | lines.extend([''] * (self.height - len(lines))) 592 | 593 | self.last_render = Vertical([self._render_line(l, i + self.scroll_offset == self.index) for i, l in enumerate(lines)]) 594 | 595 | # FIXME: Scroll bar 596 | return self.last_render 597 | 598 | def on_event(self, ev): 599 | if ev.type == 'key': 600 | change, self.index, self.scroll_offset = handle_scroll_key(ev.key, self.index, len(self.choices), self.scroll_offset, self.last_render.rect.h) 601 | if change: 602 | ev.stop() 603 | 604 | 605 | class SelectDate(Control): 606 | """A Calendar control for selecting a date. 607 | 608 | `.value` contains the date as a datetime.datetime. 609 | `.date` contains the date date as a datetime.date. 610 | """ 611 | def __init__(self, value=None, **kwargs): 612 | super(Control, self).__init__(**kwargs) 613 | self.can_focus = True 614 | self.value = value or datetime.datetime.now() 615 | self.controls = [] 616 | 617 | def _render_monthcell(self, cell): 618 | if not cell: 619 | return Display('') 620 | attr = 0 621 | if cell == self.value.day: 622 | attr = curses.A_STANDOUT if self.has_focus else curses.A_UNDERLINE 623 | return Display(str(cell), attr=attr) 624 | 625 | @property 626 | def date(self): 627 | return self.value.date() 628 | 629 | def render(self, app): 630 | self.has_focus = app.contains_focus(self) 631 | cal_data = calendar.monthcalendar(self.value.year, self.value.month) 632 | cal_header = [[Display(t, fg=green) for t in calendar.weekheader(3).split(' ')]] 633 | 634 | assert(len(cal_data[0]) == len(cal_header[0])) 635 | 636 | cells = [[self._render_monthcell(cell) for cell in row] 637 | for row in cal_data] 638 | 639 | month_name = Display('%s, %s' % (self.value.strftime('%B'), self.value.year)) 640 | grid = Grid(cal_header + cells, align_right=True) 641 | 642 | return Vertical([month_name, grid]) 643 | 644 | def on_event(self, ev): 645 | if ev.type == 'key': 646 | if ev.what == ord('t'): 647 | self.value = datetime.datetime.now() 648 | ev.stop() 649 | if ev.what == curses.KEY_LEFT: 650 | self.value += datetime.timedelta(days=-1) 651 | ev.stop() 652 | if ev.what == curses.KEY_RIGHT: 653 | self.value += datetime.timedelta(days=1) 654 | ev.stop() 655 | if ev.what == curses.KEY_UP: 656 | self.value += datetime.timedelta(weeks=-1) 657 | ev.stop() 658 | if ev.what == curses.KEY_DOWN: 659 | self.value += datetime.timedelta(weeks=1) 660 | ev.stop() 661 | 662 | 663 | class Composite(Control): 664 | """Horizontal composition of other controls.""" 665 | def __init__(self, controls, margin=0, **kwargs): 666 | super(Composite, self).__init__(**kwargs) 667 | self.controls = controls 668 | self.margin = margin 669 | 670 | def render(self, app): 671 | m = Display(' ' * self.margin) 672 | xs = [c.render(app) for c in self.controls] 673 | rendered = list(itertools.chain(*list(zip(xs, itertools.repeat(m))))) 674 | return Horizontal(rendered) 675 | 676 | def on_event(self, ev): 677 | propagate_focus(ev, self.controls, ev.app.layer(self), 678 | [curses.KEY_LEFT, SHIFT_TAB], 679 | [curses.KEY_RIGHT, curses.ascii.TAB]) 680 | 681 | def _focus_order(self, key): 682 | """If we enter the control from the bottom, still focus the first element.""" 683 | return (reversed 684 | if key in [curses.KEY_LEFT, SHIFT_TAB] else 685 | ident) 686 | 687 | 688 | class Popup(Control): 689 | """Show a modal popup that contains another control. 690 | 691 | After instantiating this object, call `popup.show(app)`. 692 | 693 | The popup is automatically removed when an ENTER or ESC 694 | keypress escapes the focused control, but on_close will 695 | only be called if ENTER was used to remove the popup. 696 | """ 697 | def __init__(self, inner, on_close, x=-1, y=-1, caption='', underscript='', **kwargs): 698 | super(Popup, self).__init__(**kwargs) 699 | self.x = x 700 | self.y = y 701 | self.inner = inner 702 | self.on_close = on_close 703 | self.caption = caption 704 | self.underscript = underscript 705 | 706 | def render(self, app): 707 | inner = Box(self.inner.render(app), 708 | x_fill=False, 709 | caption=Display(self.caption), 710 | underscript=Display(self.underscript)) 711 | if self.x == -1 or self.y == -1: 712 | return Centered(inner) 713 | return Positioned(inner, x=self.x, y=self.y) 714 | 715 | def children(self): 716 | return [self.inner] 717 | 718 | def show(self, app): 719 | self.layer = app.push_layer(self) 720 | 721 | def on_event(self, ev): 722 | if ev.type == 'key': 723 | if ev.key == curses.ascii.ESC: 724 | self.layer.remove() 725 | ev.stop() 726 | if is_enter(ev): 727 | self.on_close(self, ev.app) 728 | self.layer.remove() 729 | ev.stop() 730 | 731 | 732 | def EditPopup(app, on_close, value='', caption=''): 733 | """Show a popup with to edit a value.""" 734 | Popup(Edit(value=value, min_size=30, fg=cyan), caption=caption, on_close=on_close).show(app) 735 | 736 | 737 | class Combo(Control): 738 | """A SelectList in a popup.""" 739 | def __init__(self, choices, index=0, **kwargs): 740 | super(Combo, self).__init__(**kwargs) 741 | self._choices = choices 742 | self.index = index 743 | self.can_focus = True 744 | self.last_combo = None 745 | 746 | def sanitize_index(self): 747 | self.index = min(max(0, self.index), len(self.choices) - 1) 748 | return 0 <= self.index < len(self.choices) 749 | 750 | @property 751 | def choices(self): 752 | if callable(self._choices): 753 | return self._choices() 754 | return self._choices 755 | 756 | @property 757 | def value(self): 758 | if not self.sanitize_index(): 759 | return None 760 | return get_value(self.choices[self.index]) 761 | 762 | @value.setter 763 | def value(self, value): 764 | try: 765 | self.index = max(0, self.choices.index(value)) 766 | except ValueError: 767 | self.index = 0 768 | 769 | @property 770 | def caption(self): 771 | if not self.sanitize_index(): 772 | return '-unset-' 773 | return str(self.choices[self.index]) 774 | 775 | def render(self, app): 776 | attr = curses.A_STANDOUT if app.contains_focus(self) else 0 777 | self.last_combo = Display(self.caption, attr=attr) 778 | return self.last_combo 779 | 780 | def on_event(self, ev): 781 | if ev.type == 'key': 782 | if is_enter(ev): 783 | x = max(0, self.last_combo.rect.x - 2) 784 | y = max(0, self.last_combo.rect.y - 1) 785 | Popup(SelectList(self.choices, self.index), self.on_popup_close, x=x, y=y).show(ev.app) 786 | ev.stop() 787 | 788 | def on_popup_close(self, popup, app): 789 | self.index = popup.inner.index 790 | 791 | 792 | class Toasty(Control): 793 | def __init__(self, text, duration=datetime.timedelta(seconds=3), border=True, **kwargs): 794 | super(Toasty, self).__init__(**kwargs) 795 | self.text = text 796 | self.duration = duration 797 | self.border = border 798 | 799 | def render(self, app): 800 | inner = Display(self.text, fg=self.fg) 801 | if self.border: 802 | inner = Box(inner, x_fill=False) 803 | return AlignRight(inner) 804 | 805 | def show(self, app): 806 | self.layer = app.push_layer(self, modal=False) 807 | app.enqueue(self.duration, self._done) 808 | 809 | def _done(self, app): 810 | self.layer.remove() 811 | 812 | 813 | class DateCombo(Control): 814 | """A SelectDate in a popup.""" 815 | def __init__(self, value=None, **kwargs): 816 | super(DateCombo, self).__init__(**kwargs) 817 | self.value = value or datetime.datetime.now() 818 | self.can_focus = True 819 | 820 | @property 821 | def date(self): 822 | return self.value.date() 823 | 824 | def render(self, app): 825 | attr = curses.A_STANDOUT if app.contains_focus(self) else 0 826 | visual = self.value.strftime('%B %d, %Y') 827 | self.last_combo = Display(visual, attr=attr) 828 | return self.last_combo 829 | 830 | def on_event(self, ev): 831 | if ev.type == 'key': 832 | if is_enter(ev): 833 | x = max(0, self.last_combo.rect.x - 2) 834 | y = max(0, self.last_combo.rect.y - 1) 835 | Popup(SelectDate(self.value), self.on_popup_close, x=x, y=y).show(ev.app) 836 | ev.stop() 837 | 838 | def on_popup_close(self, popup, app): 839 | self.value = popup.inner.value 840 | 841 | 842 | class Time(Composite): 843 | """A time selection control.""" 844 | def __init__(self, value=None, **kwargs): 845 | self.value = value or datetime.datetime.utcnow().time() 846 | 847 | now_h = self.value.strftime('%H') 848 | now_m = '%02d' % (int(self.value.minute / 5) * 5) 849 | 850 | hours = ['%02d' % h for h in range(0, 24)] 851 | minutes = ['%02d' % m for m in range(0, 60, 5)] 852 | 853 | self.hour_combo = Combo(id='hour', choices=hours, index=hours.index(now_h)) 854 | self.min_combo = Combo(id='min', choices=minutes, index=minutes.index(now_m)) 855 | 856 | super(Time, self).__init__([ 857 | self.hour_combo, 858 | Text(':'), 859 | self.min_combo, 860 | Text(' UTC')], **kwargs) 861 | 862 | @property 863 | def time(self): 864 | return datetime.time(int(self.hour_combo.value), int(self.min_combo.value)) 865 | 866 | 867 | class Edit(Control): 868 | """Standard text edit control. 869 | 870 | Arguments: 871 | highlight, fn: a syntax highlighting function. Will be given a string, and 872 | should return a list of curses (color, attributes), one for every 873 | character. 874 | """ 875 | def __init__(self, value, min_size=0, highlight=None, **kwargs): 876 | super(Edit, self).__init__(**kwargs) 877 | self._value = value 878 | self.min_size = min_size 879 | self.can_focus = True 880 | self.cursor = len(value) 881 | self.highlight = highlight 882 | 883 | @property 884 | def value(self): 885 | return self._value 886 | 887 | @value.setter 888 | def value(self, value): 889 | self._value = value 890 | self.cursor = len(value) 891 | 892 | def render(self, app): 893 | focused = app.contains_focus(self) 894 | 895 | # Default highlighting, foreground color 896 | colorized = self.value 897 | if self.highlight: 898 | # Custom highlighting 899 | try: 900 | colorized = self.highlight(self.value) 901 | except Exception, e: 902 | logger.error(str(e)) 903 | 904 | # Make the field longer for the cursor or display purposes 905 | ext_len = max(0, max(self.cursor + 1 if focused else 0, self.min_size) - len(self.value)) 906 | colorized += ' ' * ext_len 907 | 908 | # Render that momma 909 | self.rendered = Horizontal(self._render_colorized(colorized, focused)) 910 | return self.rendered 911 | 912 | def _render_colorized(self, colorized, focused): 913 | """Split a colorized string into Display() slices.""" 914 | base_attr = 0 915 | if focused: 916 | base_attr = curses.A_UNDERLINE 917 | 918 | frag_list = [] 919 | parts = colorized.split('\0') 920 | chars_so_far = 0 921 | for i in range(0, len(parts), 2): 922 | # i is regular, i+1 is colorized (if it's there) 923 | frag_list.append(Display(parts[i], fg=self.fg, attr=base_attr)) 924 | if focused: 925 | chars_so_far = self._inject_cursor(chars_so_far, frag_list) 926 | 927 | if i + 1 < len(parts): 928 | color, attr, text = parts[i+1].split('\1') 929 | frag_list.append(Display(text, fg=int(color), attr=base_attr+int(attr))) 930 | if focused: 931 | chars_so_far = self._inject_cursor(chars_so_far, frag_list) 932 | 933 | return frag_list 934 | 935 | def _inject_cursor(self, chars_so_far, frag_list): 936 | """If the cursor falls into this fragment, highlight it.""" 937 | last = frag_list[-1] 938 | if chars_so_far <= self.cursor < chars_so_far + len(last.text): 939 | offset = self.cursor - chars_so_far 940 | pre, hi, post = last.text[:offset], last.text[offset], last.text[offset+1:] 941 | frag_list[-1:] = [Display(pre, fg=last.fg, attr=last.attr), 942 | Display(hi, fg=last.fg, attr=last.attr + curses.A_STANDOUT), 943 | Display(post, fg=last.fg, attr=last.attr)] 944 | 945 | return chars_so_far + len(last.text) 946 | 947 | def on_event(self, ev): 948 | if ev.type == 'key': 949 | if ev.key in [CTRL_A, curses.KEY_HOME]: 950 | self.cursor = 0 951 | ev.stop() 952 | if ev.key in [CTRL_E, curses.KEY_END]: 953 | self.cursor = len(self._value) 954 | ev.stop() 955 | if ev.key in [curses.KEY_BACKSPACE, MAC_BACKSPACE]: 956 | if self.cursor > 0: 957 | self._value = self._value[:self.cursor-1] + self._value[self.cursor:] 958 | self.cursor = max(0, self.cursor - 1) 959 | ev.stop() 960 | elif ev.key in [curses.ascii.DEL, OTHER_DEL]: 961 | if self.cursor < len(self._value) - 1: 962 | self._value = self._value[:self.cursor] + self._value[self.cursor+1:] 963 | ev.stop() 964 | if ev.key == curses.KEY_LEFT and self.cursor > 0: 965 | self.cursor -= 1 966 | ev.stop() 967 | if ev.key == curses.KEY_RIGHT and self.cursor < len(self._value): 968 | self.cursor += 1 969 | ev.stop() 970 | if ev.key == CTRL_U: 971 | self.value = '' 972 | self.cursor = 0 973 | ev.stop() 974 | if 32 <= ev.key < 127: 975 | self._value = self._value[:self.cursor] + chr(ev.key) + self._value[self.cursor:] 976 | self.cursor += 1 977 | ev.stop() 978 | 979 | 980 | class AutoCompleteEdit(Edit): 981 | """Edit control with autocomplete. 982 | 983 | complete_fn is a function that gets the current word under 984 | the cursor, and should return all possible completions. 985 | """ 986 | def __init__(self, value, complete_fn, min_size=0, letters=string.letters, **kwargs): 987 | super(AutoCompleteEdit, self).__init__(value=value, min_size=min_size, **kwargs) 988 | self.complete_fn = complete_fn 989 | self.popup_visible = False 990 | self.select = SelectList([], 0, width=70, show_captions_at=30) 991 | self.popup = Popup(self.select, on_close=self.on_close, underscript='( ^N, ^P to move, Enter to select )') 992 | self.layer = None 993 | self.letters = letters 994 | 995 | def on_close(self): 996 | pass 997 | 998 | def show_popup(self, app, visible): 999 | if visible and not self.layer: 1000 | self.popup.x = self.rendered.rect.x 1001 | self.popup.y = self.rendered.rect.y + 1 1002 | self.layer = app.push_layer(self.popup, modal=False) 1003 | if not visible and self.layer: 1004 | self.layer.remove() 1005 | self.layer = None 1006 | 1007 | def set_autocomplete_options(self, options): 1008 | self.select.choices = options 1009 | 1010 | @property 1011 | def cursor_word(self): 1012 | """Return the word under the cursor. 1013 | 1014 | Returns (offset, string). 1015 | """ 1016 | i = min(self.cursor, len(self.value) - 1) # Inclusive 1017 | while (i > 0 and self.value[i] in self.letters and 1018 | self.value[i-1] in self.letters): 1019 | i -= 1 1020 | j = i + 1 # Exclusive 1021 | while (j < len(self.value) and self.value[j] in self.letters): 1022 | j += 1 1023 | return (i, self.value[i:j]) 1024 | 1025 | def replace_cursor_word(self, word): 1026 | i, current = self.cursor_word 1027 | self.value = self.value[:i] + word + self.value[i+len(current):] 1028 | 1029 | def on_event(self, ev): 1030 | super(AutoCompleteEdit, self).on_event(ev) 1031 | 1032 | if ev.app.contains_focus(self): 1033 | _, word = self.cursor_word 1034 | self.set_autocomplete_options(self.complete_fn(word)) 1035 | interesting = (len(self.select.choices) > 1 1036 | or (len(self.select.choices) == 1) and self.select.choices[0] != word) 1037 | self.show_popup(ev.app, interesting) 1038 | if ev.type == 'blur': 1039 | self.show_popup(ev.app, False) 1040 | 1041 | if ev.type == 'key' and self.layer: 1042 | if ev.key in [CTRL_J, CTRL_N]: 1043 | self.select.adjust(1) 1044 | ev.stop() 1045 | if ev.key in [CTRL_K, CTRL_P]: 1046 | self.select.adjust(-1) 1047 | ev.stop() 1048 | if is_enter(ev): 1049 | self.replace_cursor_word(self.select.value) 1050 | self.show_popup(ev.app, False) 1051 | ev.stop() 1052 | if ev.key in [curses.ascii.ESC]: 1053 | self.show_popup(ev.app, False) 1054 | ev.stop() 1055 | 1056 | 1057 | class Button(Control): 1058 | """Button which calls an event handler if hit.""" 1059 | def __init__(self, caption, on_click=None, fg=yellow, **kwargs): 1060 | super(Button, self).__init__(fg=fg, **kwargs) 1061 | self.caption = caption 1062 | self.on_click = on_click 1063 | self.can_focus = True 1064 | 1065 | def render(self, app): 1066 | return Display('[ %s ]' % self.caption, fg=self.fg, 1067 | attr=curses.A_STANDOUT if app.contains_focus(self) else 0) 1068 | 1069 | def on_event(self, ev): 1070 | if ev.type == 'key': 1071 | if is_enter(ev) or ev.what == ord(' '): 1072 | if self.on_click: 1073 | self.on_click(ev.app) 1074 | ev.stop() 1075 | 1076 | 1077 | class PreviewPane(Control): 1078 | def __init__(self, text, row_selectable=False, on_select_row=None, **kwargs): 1079 | super(PreviewPane, self).__init__(**kwargs) 1080 | self._text = text 1081 | self.can_focus = True 1082 | self.v_scroll_offset = 0 1083 | self.h_scroll_offset = 0 1084 | self.app = None 1085 | self.row_selectable = row_selectable 1086 | self.selected_row = 0 1087 | self.on_select_row = on_select_row 1088 | self._index_text() 1089 | 1090 | @property 1091 | def text(self): 1092 | return self._text 1093 | 1094 | @text.setter 1095 | def text(self, text): 1096 | self._text = text 1097 | self._index_text() 1098 | if self.last_render: 1099 | self.v_scroll_offset = max(0, min(self.v_scroll_offset, len(self.lines) - self.last_render.rect.h)) 1100 | 1101 | @property 1102 | def lines(self): 1103 | ret = [] 1104 | for l_start, l_end in self._lines: 1105 | ret.append(self._text[l_start:l_end]) 1106 | return ret 1107 | 1108 | def _index_text(self): 1109 | """Run through _text and index all the newlines in it.""" 1110 | self._lines = [] 1111 | 1112 | start = 0 1113 | newline = self._text.find('\n') 1114 | while newline != -1: 1115 | self._lines.append((start, newline)) 1116 | start, newline = newline + 1, self._text.find('\n', newline + 1) 1117 | self._lines.append((start, len(self._text))) 1118 | 1119 | def render(self, app): 1120 | self.app = app # FIXME: That's nasty 1121 | attr = 0 1122 | focused = app.contains_focus(self) 1123 | if focused: 1124 | attr = curses.A_BOLD 1125 | 1126 | MAX_HEIGHT = 1000 # No screen will ever contain more than this many lines 1127 | 1128 | display_lines = list(self._text[l_start + self.h_scroll_offset:l_end] for l_start, l_end in self._lines[self.v_scroll_offset:self.v_scroll_offset + MAX_HEIGHT]) 1129 | if self.row_selectable and focused: 1130 | hi_offset = self.selected_row - self.v_scroll_offset 1131 | self.last_render = Vertical([ 1132 | Display(line, attr=attr + (curses.A_STANDOUT if i == hi_offset else 0)) for i, line in enumerate(display_lines) 1133 | ]) 1134 | else: 1135 | self.last_render = Display(display_lines, attr=attr) 1136 | return self.last_render 1137 | 1138 | def on_event(self, ev): 1139 | if ev.type == 'key': 1140 | h_scrolls = { 1141 | curses.KEY_LEFT: -10, 1142 | ord('h'): -10, 1143 | curses.KEY_RIGHT: 10, 1144 | ord('l'): 10, 1145 | } 1146 | 1147 | if self.row_selectable: 1148 | # We scroll the focus 1149 | change, self.selected_row, self.v_scroll_offset = handle_scroll_key(ev.key, self.selected_row, len(self._lines), self.v_scroll_offset, self.last_render.rect.h, page_size=30) 1150 | else: 1151 | # We scroll the screen 1152 | change, self.v_scroll_offset, _ = handle_scroll_key(ev.key, self.v_scroll_offset, len(self._lines), self.v_scroll_offset, self.last_render.rect.h, page_size=30) 1153 | 1154 | if change: 1155 | ev.stop() 1156 | 1157 | if ev.key in h_scrolls: 1158 | new_h_scroll_offset = max(0, self.h_scroll_offset + h_scrolls[ev.key]) 1159 | if new_h_scroll_offset != self.h_scroll_offset: 1160 | self.h_scroll_offset = new_h_scroll_offset 1161 | ev.stop() 1162 | 1163 | if ev.key == ord('s'): 1164 | EditPopup(ev.app, self._save_contents, value='report.log', caption='Save to file') 1165 | if is_enter(ev) and self.row_selectable and self.on_select_row and 0 <= self.row_selectable < len(self._lines): 1166 | 1167 | l_start, l_end = self._lines[self.selected_row] 1168 | self.on_select_row(self._text[l_start:l_end], ev.app) 1169 | ev.stop() 1170 | 1171 | def _save_contents(self, box, app): 1172 | filename = box.inner.value 1173 | 1174 | try: 1175 | with file(filename, 'w') as f: 1176 | f.write(self._text) 1177 | Toasty('%s saved' % filename).show(self.app) 1178 | except Exception, e: 1179 | Toasty(str(e), duration=datetime.timedelta(seconds=5)).show(self.app) 1180 | 1181 | 1182 | class SwitchableControl(Control): 1183 | """A control that can change the control it's displaying.""" 1184 | def __init__(self, initial_control, **kwargs): 1185 | super(SwitchableControl, self).__init__(**kwargs) 1186 | self.controls.append(initial_control) 1187 | 1188 | def switch(self, control, app): 1189 | # Keep focus if we had focus before, but don't steal it otherwise 1190 | had_focus = app.contains_focus(self) 1191 | self.controls[:] = [control] 1192 | if had_focus: 1193 | control.enter_focus('', app) 1194 | 1195 | def render(self, app): 1196 | return self.controls[0].render(app) 1197 | 1198 | 1199 | #---------------------------------------------------------------------- 1200 | # FRAMEWORK classes 1201 | 1202 | 1203 | class Rect(object): 1204 | def __init__(self, app, screen, x, y, w, h): 1205 | self.app = app 1206 | self.screen = screen 1207 | self.x = x 1208 | self.y = y 1209 | self.w = w 1210 | self.h = h 1211 | 1212 | def get_color(self, fg, bg): 1213 | return self.app.get_color(fg, bg) 1214 | 1215 | def adj_rect(self, dx, dy, dw=0, dh=0): 1216 | return self.sub_rect(dx, dy, self.w - dx - dw, self.h - dy - dh) 1217 | 1218 | def sub_rect(self, dx, dy, w, h): 1219 | return Rect(self.app, self.screen, self.x + dx, self.y + dy, w, h) 1220 | 1221 | def resize(self, w, h): 1222 | return self.sub_rect(0, 0, w, h) 1223 | 1224 | def clear(self): 1225 | line = ' ' * self.w 1226 | for j in range(self.y, self.y + self.h): 1227 | self.screen.addstr(j, self.x, line) 1228 | 1229 | def __repr__(self): 1230 | return '(%s,%s,%s,%s)' % (self.x, self.y, self.w, self.h) 1231 | 1232 | 1233 | class Event(object): 1234 | def __init__(self, type, what, target, app): 1235 | self.type = type 1236 | self.key = what 1237 | self.what = what 1238 | self.target = target 1239 | self.last = None 1240 | self.propagating = True 1241 | self.app = app 1242 | 1243 | def stop(self): 1244 | self.propagating = False 1245 | 1246 | 1247 | def object_tree(root): 1248 | stack = [(None, root)] 1249 | while stack: 1250 | parent, obj = stack.pop() 1251 | yield parent, obj 1252 | children = obj.children() 1253 | stack.extend((obj, c) for c in reversed(children)) 1254 | 1255 | 1256 | class Layer(Control): 1257 | """A layer in the app, modal or non-modal. 1258 | 1259 | Non-modal layers stack, but can't be interacted with. The topmost modal layer 1260 | will be the one receiving input. 1261 | """ 1262 | 1263 | def __init__(self, root, app, modal, id): 1264 | super(Layer, self).__init__() 1265 | self.root = root 1266 | self.focused = self.root 1267 | self.app = app 1268 | self.modal = modal 1269 | self.id = id 1270 | 1271 | self._focus_first() 1272 | 1273 | def _focus_first(self): 1274 | for parent, child in object_tree(self): 1275 | if child.can_focus: 1276 | self.focus(child) 1277 | return 1278 | 1279 | def _focus_last(self): 1280 | controls = list(object_tree(self)) 1281 | controls.reverse() 1282 | for parent, child in controls: 1283 | if child.can_focus: 1284 | self.focus(child) 1285 | return 1286 | 1287 | def focus(self, ctrl): 1288 | assert(ctrl.can_focus) 1289 | self.focused.on_event(Event('blur', None, self.focused, self.app)) 1290 | self.focused = ctrl 1291 | self.focused.on_event(Event('focus', None, self.focused, self.app)) 1292 | 1293 | def children(self): 1294 | return [self.root] 1295 | 1296 | def render(self, app): 1297 | return self.root.render(app) 1298 | 1299 | 1300 | class TimerHandle(object): 1301 | def __init__(self, app, timer_id): 1302 | self.app = app 1303 | self.timer_id = timer_id 1304 | 1305 | def cancel(self): 1306 | for i, (_, _, timer_id) in enumerate(self.app.timers): 1307 | if timer_id == self.timer_id: 1308 | self.app.timers.pop(i) 1309 | break 1310 | 1311 | 1312 | class LayerHandle(object): 1313 | def __init__(self, app, layer_id): 1314 | self.app = app 1315 | self.layer_id = layer_id 1316 | 1317 | def remove(self): 1318 | for i, layer in enumerate(self.app.layers): 1319 | if layer.id == self.layer_id: 1320 | self.app.layers.pop(i) 1321 | break 1322 | 1323 | 1324 | class App(Control): 1325 | def __init__(self, root): 1326 | super(App, self).__init__() 1327 | self.exit = False 1328 | self.screen = None 1329 | self.layers = [] 1330 | self.color_cache = {} 1331 | self.color_counter = 1 1332 | self.timers = [] 1333 | self.uniq_id = 0 1334 | 1335 | self.push_layer(root) 1336 | 1337 | def enqueue(self, delta, on_time): 1338 | deadline = datetime.datetime.now() + delta 1339 | self.uniq_id += 1 1340 | self.timers.append((deadline, on_time, self.uniq_id)) 1341 | self.timers.sort() 1342 | return TimerHandle(self, self.uniq_id) 1343 | 1344 | @property 1345 | def active_layer(self): 1346 | # Return the highest modal layer 1347 | for l in reversed(self.layers): 1348 | if l.modal: 1349 | return l 1350 | assert(False) 1351 | 1352 | def push_layer(self, control, modal=True): 1353 | assert(isinstance(control, Control)) 1354 | self.uniq_id += 1 1355 | self.layers.append(Layer(control, self, modal, self.uniq_id)) 1356 | return LayerHandle(self, self.uniq_id) 1357 | 1358 | def _all_objects(self): 1359 | return object_tree(self) 1360 | 1361 | def children(self): 1362 | return self.layers 1363 | 1364 | def get_parent(self, ctrl): 1365 | for parent, child in self._all_objects(): 1366 | if child is ctrl: 1367 | return parent 1368 | return None 1369 | 1370 | def contains_focus(self, ctrl): 1371 | return self.find_ancestor(self.active_layer.focused, [ctrl]) is not None 1372 | 1373 | def find_ancestor(self, ctrl, set): 1374 | """Find parent from a set of parents.""" 1375 | while ctrl: 1376 | if ctrl in set: 1377 | return ctrl 1378 | ctrl = self.get_parent(ctrl) 1379 | 1380 | def layer(self, ctrl): 1381 | return self.find_ancestor(ctrl, self.layers) 1382 | 1383 | def get_color(self, fore, back): 1384 | tup = (fore, back) 1385 | if tup not in self.color_cache: 1386 | curses.init_pair(self.color_counter, fore, back) 1387 | self.color_cache[tup] = self.color_counter 1388 | self.color_counter += 1 1389 | return self.color_cache[tup] 1390 | 1391 | @property 1392 | def ch_wait_time(self): 1393 | if self.timers: 1394 | # Time until next timer 1395 | return max(0, int((self.timers[0][0] - datetime.datetime.now()).total_seconds() * 1000)) 1396 | # Indefinite wait 1397 | return -1 1398 | 1399 | def fire_timers(self): 1400 | now = datetime.datetime.now() 1401 | while self.timers and self.timers[0][0] <= now: 1402 | _, on_time, _ = self.timers.pop(0) 1403 | on_time(self) 1404 | 1405 | def run(self, screen): 1406 | curses.nonl() # We need Ctrl-J! 1407 | curses.curs_set(0) 1408 | self.screen = screen 1409 | while not self.exit: 1410 | self.update() 1411 | self.screen.timeout(self.ch_wait_time) 1412 | try: 1413 | c = self.screen.getch() 1414 | if c != -1: 1415 | self.dispatch_event(Event('key', c, self.active_layer.focused, self)) 1416 | except KeyboardInterrupt: 1417 | # Just another kind of event 1418 | self.dispatch_event(Event('break', None, self.active_layer.focused, self)) 1419 | self.fire_timers() 1420 | 1421 | def update(self): 1422 | h, w = self.screen.getmaxyx() 1423 | 1424 | self.screen.erase() 1425 | for layer in self.layers: 1426 | view = layer.render(self) 1427 | view.display(Rect(self, self.screen, 0, 0, w, h)) 1428 | self.screen.refresh() 1429 | 1430 | def dispatch_event(self, ev): 1431 | tgt = ev.target 1432 | while tgt and ev.propagating: 1433 | tgt.on_event(ev) 1434 | ev.last = tgt 1435 | tgt = self.get_parent(tgt) 1436 | 1437 | def on_event(self, ev): 1438 | if ev.type == 'break': 1439 | # If the break got here, re-raise it 1440 | raise KeyboardInterrupt() 1441 | 1442 | if ev.type == 'key': 1443 | if ev.key in [curses.ascii.ESC]: 1444 | self.exit = True 1445 | ev.stop() 1446 | 1447 | # If we got here with focus-shifting, set focus back to the first control 1448 | if ev.key in [curses.KEY_DOWN, curses.ascii.TAB]: 1449 | self.active_layer._focus_first() 1450 | if ev.key in [curses.KEY_UP, SHIFT_TAB]: 1451 | self.active_layer._focus_last() 1452 | 1453 | def find(self, id): 1454 | for parent, child in object_tree(self): 1455 | if child.id == id: 1456 | return child 1457 | raise RuntimeError('No such control: %s' % id) 1458 | 1459 | 1460 | def get_all(root, ids): 1461 | ret = {} 1462 | for id in ids: 1463 | obj = root.find(id) 1464 | if hasattr(obj, 'value'): 1465 | ret[id] = obj.value 1466 | return ret 1467 | 1468 | 1469 | def set_all(root, dct): 1470 | for id, value in dct.iteritems(): 1471 | try: 1472 | obj = root.find(id) 1473 | if hasattr(obj, 'value'): 1474 | obj.value = value 1475 | except RuntimeError: 1476 | pass 1477 | 1478 | 1479 | def walk(root): 1480 | reduce_esc_delay() 1481 | curses.wrapper(App(root).run) 1482 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages # Always prefer setuptools over distutils 2 | from codecs import open # To use a consistent encoding 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | # Get the long description from the relevant file (only on devhost) 8 | try: 9 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | except IOError: 12 | long_description = '' 13 | 14 | #https://packaging.python.org/en/latest/distributing.html 15 | setup( 16 | name='sailor', 17 | version='0.0.1', 18 | description='A curses widget toolkit for Python', 19 | long_description=long_description, 20 | url='https://github.com/rix0rrr/sailor', 21 | author='Rico Huijbers', 22 | author_email='rix0rrr@gmail.com', 23 | license='MIT', 24 | 25 | 26 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 27 | classifiers=[ 28 | # How mature is this project? Common values are 29 | # 3 - Alpha 30 | # 4 - Beta 31 | # 5 - Production/Stable 32 | 'Development Status :: 3 - Alpha', 33 | 34 | # Indicate who your project is intended for 35 | 'Intended Audience :: Developers', 36 | 'Environment :: Console :: Curses', 37 | 38 | # Pick your license as you wish (should match "license" above) 39 | 'License :: OSI Approved :: MIT License', 40 | 41 | # Specify the Python versions you support here. In particular, ensure 42 | # that you indicate whether you support Python 2, Python 3 or both. 43 | 'Programming Language :: Python :: 2', 44 | 'Programming Language :: Python :: 2.6', 45 | 'Programming Language :: Python :: 2.7', 46 | 'Programming Language :: Python :: 3', 47 | 'Programming Language :: Python :: 3.2', 48 | 'Programming Language :: Python :: 3.3', 49 | 'Programming Language :: Python :: 3.4', 50 | ], 51 | 52 | # What does your project relate to? 53 | keywords='ui, ncurses', 54 | 55 | # You can just specify the packages manually here if your project is 56 | # simple. Or you can use find_packages(). 57 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 58 | 59 | # List run-time dependencies here. These will be installed by pip when your 60 | # project is installed. For an analysis of "install_requires" vs pip's 61 | # requirements files see: 62 | # https://packaging.python.org/en/latest/requirements.html 63 | install_requires=[], 64 | ) 65 | 66 | --------------------------------------------------------------------------------