Add a new Window class with read/write primitives

This commit is contained in:
Andrew Miner
2019-07-13 19:52:48 -06:00
parent bfcab3ef96
commit 63269ac7e0
7 changed files with 347 additions and 69 deletions
+5 -1
View File
@@ -1,11 +1,15 @@
"""A python UI framework for command-line applications using curses.""" """A python UI framework for command-line applications using curses."""
from .attribute_mask import AttributeMask
from .logger import Logger, LogLevel from .logger import Logger, LogLevel
from .window import Window
from .session import Session # uses Logger from .session import Session # uses Logger, Window
__all__ = [ __all__ = [
"AttributeMask",
"Logger", "Logger",
"LogLevel", "LogLevel",
"Session", "Session",
"Window",
] ]
+92
View File
@@ -0,0 +1,92 @@
"""Define the AttributeMask class."""
import curses
########################################################################################################################
class AttributeMask(object):
"""AttributeMask defines the attributes associated with a certain part of the screen."""
def __init__(self, value: int=curses.A_NORMAL):
"""Create a new Attribute."""
self._value = curses.A_NORMAL
self.value = value
# Properties ###################################################################################
@property
def value(self) -> int:
"""Get the actual numeric value underlying the mask."""
return self._value
@value.setter
def value(self, value: int):
if not isinstance(value, int):
raise TypeError(f"value must be an int, but was a {type(value)}")
self._value = value
# Flag Properties ##############################################################################
@property
def blink(self) -> bool:
"""Get whether the text is blinking."""
return self._read(curses.A_BLINK)
@blink.setter
def blink(self, value: bool):
self._assign(curses.A_BLINK, value)
@property
def bold(self) -> bool:
"""Get whether the text is bolded."""
return self._read(curses.A_BOLD)
@bold.setter
def bold(self, value: bool):
self._assign(curses.A_BOLD, value)
@property
def dim(self) -> bool:
"""Get whether the text is dimmed."""
return self._read(curses.A_DIM)
@dim.setter
def dim(self, value: bool):
self._assign(curses.A_DIM, value)
@property
def standout(self) -> bool:
"""Get whether the text is standout."""
return self._read(curses.A_STANDOUT)
@standout.setter
def standout(self, value: bool):
self._assign(curses.A_STANDOUT, value)
@property
def underline(self) -> bool:
"""Get whether the text is underline."""
return self._read(curses.A_UNDERLINE)
@underline.setter
def underline(self, value: bool):
self._assign(curses.A_UNDERLINE, value)
# Private ######################################################################################
def _assign(self, flag: int, value: bool):
if value:
self._set(flag)
else:
self._clear(flag)
def _clear(self, flag: int):
self.value = self.value & (~ flag)
def _read(self, flag: int) -> bool:
return self.value & flag != 0
def _set(self, flag: int):
self.value = self.value | flag
+53
View File
@@ -0,0 +1,53 @@
"""Unit tests for the AttributeMask class."""
import curses
import sure
from mamba import before, description, it
from pycursesui import AttributeMask
__all__ = []
assert sure # prevent linter errors
########################################################################################################################
with description("AttributeMask:", "unit") as self:
with description("starting with a normal mask"):
with before.each:
self.mask = AttributeMask()
with it("has all its flags turned off"):
self.mask.blink.should.be.false
self.mask.bold.should.be.false
self.mask.underline.should.be.false
with description("after activating one of the flags"):
with before.each:
self.mask.bold = True
with it("only has the bold flag turned on"):
self.mask.blink.should.be.false
self.mask.bold.should.be.true
self.mask.underline.should.be.false
with it("has the expected value"):
self.mask.value.should.equal(curses.A_NORMAL | curses.A_BOLD)
with description("after switching the flag for another"):
with before.each:
self.mask.bold = False
self.mask.underline = True
with it("reports the flags having switched"):
self.mask.blink.should.be.false
self.mask.bold.should.be.false
self.mask.underline.should.be.true
with it("reports the correct value"):
self.mask.value.should.equal(curses.A_NORMAL | curses.A_UNDERLINE)
+77 -64
View File
@@ -2,7 +2,8 @@
import curses import curses
from pycursesui import Logger from pycursesui import Logger, Window
from typing import Callable, Tuple
__all__ = ["Session"] __all__ = ["Session"]
@@ -14,7 +15,7 @@ class Session(object):
def __init__(self, logger=None): def __init__(self, logger=None):
"""Create a new Session.""" """Create a new Session."""
self._screen = None self._window = None
self.logger = logger self.logger = logger
# Properties ################################################################################### # Properties ###################################################################################
@@ -22,7 +23,7 @@ class Session(object):
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
"""Get whether the session is currently active.""" """Get whether the session is currently active."""
return (self.screen is not None) return (self.window is not None)
@property @property
def logger(self) -> Logger: def logger(self) -> Logger:
@@ -31,79 +32,91 @@ class Session(object):
@logger.setter @logger.setter
def logger(self, value: Logger): def logger(self, value: Logger):
if value is None: value = value if value is not None else Logger()
value = Logger()
self._logger = value self._logger = value
@property @property
def screen(self): def window(self) -> Window:
"""Get the screen associated with this session (if any).""" """Get the window associated with this session (if any)."""
return self._screen return self._window
# Magic Methods ################################################################################ # Public Methods ###############################################################################
def __enter__(self): def start(self) -> "Session":
"""Enter a session.""" """
Start a new session.
This will take over the current TTY and begin a curses session. The main window will be available from this
object's `window` property. The `stop` method *must* be called to restore the TTY back to its original
condition.
"""
self.logger.info("Starting curses session") self.logger.info("Starting curses session")
try:
self._screen = curses.initscr()
except Exception as e:
self.logger.error("Could not initialize a curses screen", e)
curses.endwin()
raise e
try: raw_window, error = self._attempt(lambda: curses.initscr())
curses.noecho() if raw_window:
except Exception as e: self._window = Window(raw_window)
self.logger.error("Could not set up no ech mode", e) if error:
curses.echo() self.logger.error("could not initialize a curses window", error)
curses.endwin() self._attempt(lambda: curses.endwin())
raise e return None
try: _, error = self._attempt(lambda: curses.start_color())
curses.cbreak() if error:
except Exception as e: self.logger.error("could not start color session", error)
self.logger.error("Could not set up character break mode", e) self._attempt(lambda: curses.endwin())
curses.nocbreak()
curses.echo()
curses.endwin()
raise e
try: _, error = self._attempt(lambda: curses.noecho())
self.screen.keypad(True) if error:
except Exception as e: self.logger.error("Could not set up no echo mode", error)
self.logger.error("Could not set up keypad", e) self._attempt(lambda: curses.echo())
self.screen.keypad(False) self._attempt(lambda: curses.endwin())
curses.nocbreak()
curses.echo() _, error = self._attempt(lambda: curses.cbreak())
curses.endwin() if error:
raise e self.logger.error("Could not set up chracter break mode", error)
self._attempt(lambda: curses.cnobreak())
self._attempt(lambda: curses.echo())
self._attempt(lambda: curses.endwin())
_, error = self._attempt(lambda: raw_window.keypad(True))
if error:
self.logger.error("Could not set up keypad", error)
self._attempt(lambda: raw_window.keypad(False))
self._attempt(lambda: curses.cnobreak())
self._attempt(lambda: curses.echo())
self._attempt(lambda: curses.endwin())
return self return self
def stop(self) -> "Session":
"""Stop the current session."""
self.logger.info("Shutting down curses session")
self._attempt(lambda: self.window.raw.keypad(False))
self._attempt(lambda: curses.nocbreak())
self._attempt(lambda: curses.echo())
self._attempt(lambda: curses.endwin())
self._window = None
return self
# Magic Methods ################################################################################
def __enter__(self) -> "Session":
"""Enter a session."""
return self.start()
def __exit__(self, type, value, traceback): def __exit__(self, type, value, traceback):
"""Exit a session.""" """Exit a session."""
self.logger.info("Shutting down curses session") self.stop()
try:
self.screen.keypad(False)
except Exception as e:
self.logger.error("Could not reset keypad", e)
try:
curses.nocbreak()
except Exception as e:
self.logger.error("Could not reset character break mode", e)
try:
curses.echo()
except Exception as e:
self.logger.error("Could not reset echo mode", e)
try:
curses.endwin()
except Exception as e:
self.logger.error("Could not shut down curses session", e)
self._screen = None
return False return False
# Private Methods ##############################################################################
def _attempt(self, task: Callable) -> Tuple[object, Exception]:
result, error = None, None
try:
result = task()
except Exception as e:
error = e
return result, error
+60
View File
@@ -0,0 +1,60 @@
"""Define the Window class."""
from pycursesui import AttributeMask
########################################################################################################################
ENCODING = "ascii"
########################################################################################################################
class Window(object):
"""Window provides a wrapper around a raw curses window."""
def __init__(self, raw):
"""Create a new Window wrapper."""
self._raw = None
self.raw = raw
# Properties ###################################################################################
@property
def raw(self):
"""Get the raw curses window wrapped by this object."""
return self._raw
@raw.setter
def raw(self, value):
if self._raw is not None:
raise ValueError("cannot change raw once initialized")
if value is None:
raise ValueError("a value must be provided for raw")
self._raw = value
# Public Methods ###############################################################################
def read(self, x: int, y: int, length: int=1) -> str:
"""Read a string from the screen at the given location."""
return self.raw.instr(y, x, length).decode(ENCODING)
def write(self, value: str, x: int, y: int, length: int=-1, attributes: AttributeMask=None):
"""
Write a portion of a string onto the window at a certain location.
Arguments:
value: the string to be written
x: the x-coordinate of where the first character should be placed
y: the y-coordinate of where the first character should be placed
length: the maximum number of characters to write
attributes: an Attributes object giving the attributes to be applied
"""
attributes = attributes if attributes is not None else AttributeMask()
if (length >= 0) and (len(value) > length):
value = value[0:length]
self.raw.addstr(y, x, value, attributes.value)
self.raw.refresh()
return self
+54
View File
@@ -0,0 +1,54 @@
"""Unit tests for the Window class."""
import sure
from io import StringIO
from mamba import after, before, description, it
from pycursesui import Logger, Session
from pycursesui.logger import LogLevel
__all__ = []
assert sure # prevent linter errors
########################################################################################################################
with description("Window:", "unit") as self:
with before.each:
self.session = None
try:
self.stdout = StringIO()
self.logger = Logger().add_channel("debug", self.stdout, LogLevel.DEBUG)
self.session = Session(self.logger).start()
if self.session is not None:
self.window = self.session.window
except Exception as e:
print(f"log:\n>>>\n{self.stdout.getvalue()}\n<<<\n")
raise e
with after.each:
if self.session is not None:
self.session.stop()
with description("using the default window from a new session"):
with it("doesn't contain any text in the test region"):
self.window.read(0, 0, 10).should.equal(" ")
with description("after writing a string into the test region"):
with before.each:
self.window.write("alpha", 0, 0)
with it("now contains the written text"):
self.window.read(0, 0, 10).should.equal("alpha ")
with description("after multiple overlapping writes"):
with before.each:
self.window.write("alpha", 0, 0).write("bravo", 3, 0)
with it("should have replaced the last portion of the first word"):
self.window.read(0, 0, 10).should.equal("alpbravo ")
+6 -4
View File
@@ -6,10 +6,12 @@ from pycursesui import Logger, Session
######################################################################################################################## ########################################################################################################################
logger = Logger() logger = Logger().add_file_channel("main", "session.log")
logger.add_file_channel("main", "session.log")
with Session(logger) as session: with Session(logger) as session:
session.screen.border() session.window.write("alpha", 10, 10)
session.screen.refresh() time.sleep(1)
session.window.write("bravo", 10, 11)
time.sleep(1)
session.window.write("charlie", 10, 12)
time.sleep(3) time.sleep(3)