0

Just for fun, I am implementing a python program to generate Ulam spirals (https://en.wikipedia.org/wiki/Ulam_spiral) of arbitrary size. The program receives the size of the spiral and eventually the program prints the spiral on stdout.

I was looking at how to improve the display of the spiral on the terminal, especially if the spiral is very large or the terminal window is a bit short, causing characters to overlap and break the image. I found out that ncourses (https://en.wikipedia.org/wiki/Ncurses) can be used for this, as it can create scrollable pads inside the windows that should not overlap.

With this in mind, I am thinking of a layout like this:

|--------------------------------------|--------------------------------------|
| Application logs                     | Spiral goes here                     |
|                                      |                                      |
|                                      |                                      |
|                                      |                                      |
|                                      |                                      |
|                                      |                                      |
|                                      |                                      |
|                                      |                                      |
|--------------------------------------|--------------------------------------|

The "Application logs" are implemented with the python logging library, with a custom handler that makes the ads and refreshes the pad itself. So far, the code I have for this:

main.py

import logging
import curses
from curses import wrapper
from utils.log_handler import CursesHandler

import signal

##logger config
log = logging.getLogger()
log.setLevel(logging.getLevelName('INFO'))
log_formatter = logging.Formatter("%(asctime)s [%(processName)s]-[%(levelname)s] [%(threadName)s]: %(message)s")

def start_cli_windows(stdscr):

    current_windows = []

    height,width = stdscr.getmaxyx()
    middle_columns = width // 2

    ulam_spiral_pad = curses.newpad(height, middle_columns)

    ulam_spiral_pad.addstr(0, 0, "*** SPIRAL GOES HERE ***")

    ##actual spiral on the right, from the middle to the end
    ulam_spiral_pad.refresh(0, 0, 0, middle_columns ,height, width)

    ##set up logger, with the position for the logger
    console_handler = CursesHandler(screen_top_left_col=0,screen_top_left_row=0,screen_bottom_right_col=middle_columns,screen_bottom_right_row=height)
    console_handler.setFormatter(log_formatter)
    log.addHandler(console_handler)

    ##Add our own handler as a window for our application
    current_windows.append(console_handler)

    ## let's do some testing
    for i in range(1000):
        log.info(f"This is the log number {i}")

    console_handler.poll_for_input()



def handler(signum, frame):
    #just quit, the wrapper will clean up
    exit(0)

if __name__ == "__main__":
    signal.signal(signal.SIGINT, handler)
    wrapper(start_cli_windows)

utils/log_handler.py

import logging
import curses
from utils.curses_wrapper import CursesWindow


class CursesHandler(logging.Handler, CursesWindow):
    """
    Class that overrides a Logging handler, so it will point to a curses window instead of a regular 
    terminal or file. The class will create the padding at the specified parameters, and also will scroll if required 
    """
    def __init__(self, screen_top_left_col,screen_top_left_row,screen_bottom_right_col,screen_bottom_right_row):
        logging.Handler.__init__(self)
        CursesWindow.__init__(self, screen_top_left_col,screen_top_left_row,screen_bottom_right_col,screen_bottom_right_row)

    def emit(self, record):
        try:
            msg = self.format(record)
            self._screen.addstr(f"\n{msg}")
            self._refresh_screen()
        except (KeyboardInterrupt, SystemExit):
            raise
        except Exception as e:
            self.handleError(record)

utils/curses_wrapper.py

import curses

class CursesWindow():

    def __init__(self,screen_top_left_col,screen_top_left_row,screen_bottom_right_col,screen_bottom_right_row):
        self._screen = curses.newpad(screen_bottom_right_row,screen_bottom_right_col)
        self._screen.scrollok(True)
        self._screen.keypad(1)
        # The upper left corner of the pad region to be displayed are set by the following parameters. As this is new,
        # init with 0, but scrolling may change this
        self._min_row_to_display = 0
        self._min_col_to_display = 0
        # clipping box on the screen within which the pad region is to be displayed
        self._screen_top_left_col = screen_top_left_col
        self._screen_top_left_row = screen_top_left_row
        self._screen_bottom_right_col = screen_bottom_right_col
        self._screen_bottom_right_row = screen_bottom_right_row

    @property
    def min_row_to_display(self):
        return self._min_row_to_display

    @property
    def min_col_to_display(self):
        return self._min_col_to_display

    @property
    def window_position(self):
        return (self._screen_top_left_col,self._screen_top_left_row,self._screen_bottom_right_col,self._screen_bottom_right_row)
    
    @window_position.setter
    def set_window_position(self, position_tuple):
        self._screen_top_left_col,self._screen_top_left_row,self._screen_bottom_right_col,self._screen_bottom_right_row = position_tuple
        self._refresh_screen()
    
    def _refresh_screen(self):
        self._screen.refresh(self._min_row_to_display, 
                self._min_col_to_display, 
                self._screen_top_left_row, 
                self._screen_top_left_col, 
                self._screen_bottom_right_row,
                self._screen_bottom_right_col)

    def scroll_window_up(self):
        self._screen.scroll(-1)
        self._refresh_screen()

    def scroll_window_down(self):
        self._screen.scroll(1)
        self._refresh_screen()
     
    def poll_for_input(self):
        QUIT_PROGRAM = False

        while not QUIT_PROGRAM:
            c = self._screen.getch()
            if c == ord('q'):
                QUIT_PROGRAM = True
            elif c == curses.KEY_UP:
                self.scroll_window_up()
            elif c == curses.KEY_DOWN:
                self.scroll_window_down()

This code kinds of works, as it will let me add strings, and auto scroll when the end of the space is reached. However, when I try to scroll, it kinds of removes the data that is there. Showing gif as an example:

sample for program output

As you can see, the lines are lost forever once the scroll moves. My hypothesis is that the refresh function repaints the viewport, however, it is not painting the content that is inside the pad. A possible reason is that I am telling the refresh to always show the coordinates (0,0) when in reality I should use the line number I want the scroll start from.

Most of the samples I saw online assume the number of lines to show is known, either because the data is on an array, or there is a way to calculate the number beforehand. That way, the new position, and refresh can be done accurately. However, in this case, I don't know how many lines will be on the log, as I just receive the stream of data to print, and then add it to the pad.

So, the question: Is there a way to get how many lines are printed on the pad? Lines can be wrapped, so one line not necessarily correlates to a one-log message in this case. I tried the following approaches:

  • Using window.getyx(), which returns the position of the cursor relative to the pad but it returns the height of the component when the cursor is at the button. For example, I expected to return 1000 (as those are the number of lines on the pad) but it returns 100 (the number of lines of my terminal)
  • Using window.getyx(), however, it returns the boundaries of the pad, not the data.

If needed, I can implement a circular buffer or something that will hold the data and use that as a backend, I think. However, first I want to know if I am missing something here.

Thanks in advance for the help!

  • The basic problem is that your pad isn't larger than the screen, so there's no allowance for the extra scrolling that you want. – Thomas Dickey Aug 16 '22 at 07:42
  • Hi @ThomasDickey. I think you are right. I understood that the pad was an infinite space, but it is not. It has a limit on the number of lines it can hold. – Eduardo Gamboa Ureña Aug 16 '22 at 14:00

0 Answers0