#!/usr/bin/env python3 """A curses+pad experiment.""" import curses import math import typing # For debugging: # from pudb.remote import set_trace # set_trace(term_size=(140, 32)) from typing import TYPE_CHECKING if TYPE_CHECKING: from _curses import _CursesWindow as Window else: from typing import Any as Window def row_col_to_char(pad_max_row: int, pad_max_column: int, row: int, column: int) -> str: """ Convert a row, column into a magnitude, and from there convert it to character in a..z. Why? Because we want to put some simple ASCII art in the pad for testing. This gives us something to scroll and search. """ pad_max_row_over_2 = pad_max_row / 2.0 pad_max_column_over_2 = pad_max_column / 2.0 sqrt_of_2 = math.sqrt(2.0) # assert side_over_2 == 50.0 shifted_row = row - pad_max_row_over_2 # assert -50.0 <= shifted_row <= 50 shifted_column = column - pad_max_column_over_2 # assert -50.0 <= shifted_column <= 50 scaled_row = shifted_row / pad_max_row_over_2 # assert -1.0 <= scaled_row <= 1.0 scaled_column = shifted_column / pad_max_column_over_2 # assert -1.0 <= scaled_column <= 1.0 magnitude = math.sqrt(scaled_row ** 2.0 + scaled_column ** 2.0) # assert 0.0 <= magnitude <= sqrt_of_2 scaled_magnitude = magnitude / sqrt_of_2 # assert 0.0 <= scaled_magnitude <= 1.0 color_char = chr(int(scaled_magnitude * 25.0) + ord('a')) # assert 'a' <= color_char <= 'z' return color_char def setup() -> Window: """Set up curses.""" stdscr = curses.initscr() curses.noecho() curses.raw() stdscr.keypad(True) # This refresh is a little weird, but it's needed to get the pad to be displayed. stdscr.refresh() return stdscr def teardown() -> None: """Tear down curses.""" curses.noraw() curses.echo() curses.endwin() def circle(pad_max_row: int, pad_max_column: int) -> typing.List[str]: """Generate a weird sort of sphere using ASCII characters.""" text_list = [] # These loops fill the pad with letters for row in range(0, pad_max_row): line_list = [] for column in range(0, pad_max_column): if row == pad_max_row - 1 and column == pad_max_column - 1: # Curses doesn't want to put a character in the lower right corner continue color_char = row_col_to_char(pad_max_row, pad_max_column, row, column) line_list.append(color_char) text_list.append(''.join(line_list)) return text_list def put_stuff_on_pad(pad_max_row: int, pad_max_column, text: typing.List[str]): """Display a weird sort of sphere using ASCII characters.""" pad = curses.newpad(pad_max_row, pad_max_column) # These loops fill the pad with letters for string_index, string in enumerate(text): for color_char_index, color_char in enumerate(string): if string_index == pad_max_row - 1 and color_char_index == pad_max_column - 1: # Curses doesn't want to put a character in the lower right corner continue pad.addch(string_index, color_char_index, color_char) return pad def control(char: str) -> int: """Convert a letter to a control-key-esque version of itself.""" if 'a' <= char <= 'z': return ord(char) - ord('a') + 1 if 'A' <= char <= 'Z': return ord(char) - ord('A') + 1 raise ValueError('char not a letter') def linenos_to_search(current: int, maximum: int): """Generate line numbers for a search of text.""" yield from range(current + 1, maximum) yield from range(0, current) class DisplayData: """Hold and update information related to our curses screen and pad, including their dimensions.""" def __init__(self, stdscr: Window, pad: Window, text: typing.List[str]) -> None: """Initialize.""" self.row = 0 self.column = 0 self.pad_row = 2 self.pad_column = 0 self.text = text self.stdscr = stdscr (self.pad_max_row, self.pad_max_column) = pad.getmaxyx() self.update(self.stdscr, pad) def update(self, stdscr: Window, pad: Window) -> None: """Update the info.""" self.pad_width, self.pad_height = stdscr.getmaxyx() self.pad_width = self.pad_width - self.pad_row - 2 self.pad_height = self.pad_height - self.pad_column - 1 self.half_pad_height = self.pad_height // 2 pad.refresh( self.row, self.column, self.pad_row, self.pad_column, self.pad_row + self.pad_width, self.pad_column + self.pad_height, ) stdscr.refresh() def forward_n_lines(self, n: int) -> None: """Advance the 'cursor' n lines forward.""" self.row = min(self.pad_max_row - 1 - self.pad_width, self.row + n) def back_n_lines(self, n: int) -> None: """Move the 'cursor' n lines back.""" self.row = max(0, self.row - n) def last_line(self) -> None: """Move the 'cursor' to the last line of the buffer.""" self.row = self.pad_max_row - 1 - self.pad_height def last_char(self) -> None: """Move the 'cursor' to the last character of the line.""" self.column = self.pad_max_column - 1 - self.pad_width def first_char(self) -> None: """Move the 'cursor' to the first character of the line.""" self.column = 0 def forward_n_chars(self, n: int) -> None: """Move the 'cursor' n characters to the right.""" self.column = min(self.pad_max_column - 1 - self.pad_height, self.column + n) def back_n_chars(self, n: int) -> None: """Move the 'cursor' n characters to the left.""" self.column = max(0, self.column - 1) def jump_to_line(self, n: int) -> None: """Set the 'cursor' to the nth line.""" self.row = n def scroll_pad(stdscr: Window, pad: Window, text: typing.List[str]): """Scroll around the pad.""" stdscr.addstr(0, 0, 'You can navigate with the arrow keys, vi keys or emacs keys. Hit enter to select.') dd = DisplayData(stdscr, pad, text) iterationno = 0 while True: stdscr.addstr(0, 0, f'At top of while loop: {iterationno}') iterationno += 1 dd.update(stdscr, pad) ch = stdscr.getch() if ch in (curses.KEY_RESIZE, ): dd.update(stdscr, pad) elif ch in (ord('h'), curses.KEY_LEFT, control('b')): # dd.column = max(0, dd.column - 1) dd.back_n_chars(1) elif ch in (ord('l'), curses.KEY_RIGHT, control('f')): # dd.column = min(pad_max_column - 1 - dd.pad_height, dd.column + 1) dd.forward_n_chars(1) elif ch in (ord('j'), curses.KEY_DOWN, control('n')): dd.forward_n_lines(1) elif ch in (ord('k'), curses.KEY_UP, control('p')): # dd.row = max(0, dd.row - 1) dd.back_n_lines(1) elif ch in (ord('0'), control('a'), curses.KEY_HOME): # dd.column = 0 dd.first_char() elif ch in (ord('$'), control('e'), curses.KEY_END): # dd.column = pad_max_column - 1 - dd.pad_height dd.last_char() elif ch in (ord('G'), ): # There are other common character sequences for this, but I don't think they're a single character. dd.last_line() elif ch in (ord('q'), 27, control('c')): # 27 is escape break elif ch in (ord(' '), curses.KEY_NPAGE, ord('d')): dd.forward_n_lines(dd.pad_height) elif ch in (curses.KEY_PPAGE, ord('u')): # dd.row = max(dd.row - dd.pad_height, 0) dd.back_n_lines(dd.pad_height) elif ch in (control('d'), ): dd.forward_n_lines(dd.half_pad_height) elif ch in (control('u'), ): # dd.row = max(dd.row - dd.half_pad_height, 0) dd.back_n_lines(dd.half_pad_height) elif ch in (ord('/'), control('s')): (physical_max_y, physical_max_x) = dd.stdscr.getmaxyx() dd.stdscr.move(physical_max_y - 1, 0) dd.stdscr.addch('/') curses.echo() search_string = dd.stdscr.getstr(physical_max_y - 1, 1, physical_max_x - 2).decode('utf-8') curses.noecho() dd.stdscr.move(physical_max_y - 1, 0) dd.stdscr.clrtoeol() for lineno in linenos_to_search(dd.row, len(dd.text)): line = dd.text[lineno] if search_string in line: dd.jump_to_line(lineno) dd.stdscr.move(physical_max_y - 1, 0) break else: stdscr.addstr(0, 20, f'got mystery character {ch}') pass def main() -> None: """Start the ball rolling.""" pad_max_column = 200 pad_max_row = 600 stdscr = setup() text = circle(pad_max_row, pad_max_column) pad = put_stuff_on_pad(pad_max_row, pad_max_column, text) scroll_pad(stdscr, pad, text) teardown() if __name__ == '__main__': main()