def _process_to(self, index): if self._last_index_processed is None and index == 0: self.current_position = self.starting_point elif (self._last_index_processed is None or index != self._last_index_processed + 1): return self._reprocess_from_start(index) if self.locals.on_rendering_skipped_positions: skipped = self.locals.on_rendering_skipped_positions[:] self.locals.on_rendering_skipped_positions.clear() else: skipped = () for mark_here, mark_origin in self.marks.get_full( index, self.current_position, skipped=skipped): mark_here.context = self.context mark_here.pos = self.current_position if mark_here.attributes or mark_here.pop_attributes: self._context_push(mark_here.attributes, mark_here.pop_attributes, mark_origin, index) if mark_here.moveto: mtx = mark_here.moveto[0] mty = mark_here.moveto[1] mtx = mtx if mtx is not RETAIN_POS else self.current_position.x mty = mty if mty is not RETAIN_POS else self.current_position.y self.current_position = V2(mtx, mty) if mark_here.rmoveto: self.current_position += V2(mark_here.rmoveto) self._last_index_processed = index return self.context
def bezier(self, pos1, pos2, pos3, pos4, *extra): """Draws a bezier curve given the control points Args: pos1 (2-sequence): Fist control point pos2 (2-sequence): Second control point pos3 (2-sequence): Third control point pos4 (2-sequence): Fourth control point extra Tuple[2-sequence]: n-sets of 3 more control points to keep drawing. """ pos1 = V2(pos1) pos2 = V2(pos2) pos3 = V2(pos3) pos4 = V2(pos4) x, y = pos1 t = 0 step = 1 / (abs(pos4 - pos3) + abs(pos3 - pos2) + abs(pos2 - pos1)) self.set((x, y)) while t <= 1.0: x, y = (pos1 * (1 - t)**3 + pos2 * 3 * (1 - t)**2 * t + pos3 * 3 * (1 - t) * t**2 + pos4 * t**3) self.set((round(x), round(y))) t += step if len(extra) >= 3: self.bezier(pos4, extra[0], extra[1], extra[2], *extra[3:])
def __init__( self, text, mark_sequence, text_plane=None, context=None, starting_point=None ): """ Args: text (Sequence): the stream of characters to be rendered - it can be a string or a list of 1-grapheme strings. mark_sequence (Mapping): A mappign with Mark objects. The keys either represent index positions on the text where the mark will be processed, or they can be at the special index "config" denoting marks that are to have their indexes processed according to other enviroment circunstances (like the current "tick" - and possibly 'current position') The value at each item can contain a single Mark or a of Markers. text_plane (terminedia.text.planes.TextPlane): area where the output is to be rendered on iterating. The Text object will be searched for aditional "Mark" objects that will compose the syle and position when encountered (they are less prioritary than the Marks passed in mark_sequence) If no Text object is given, the instance may still be iterated to retrieve a sequence of char, context and position - for example, when generating output directly to a tty. context (terminedia.Context): parent context. By default the context attached to the given text_plane is used starting_point: first position to be yielded when iteration starts (from which rules apply according to context.direction and others given by the matched "Mark" objects. Defaults to (0, 0) Helper class to render text that will both hold embedded style information, conveyed in "Mark" objects (with information like "at position 10, push foreground color 'red'"), and respect Mark objects embedded in the "text_plane" associanted rendering space. Style changes are all on top of a given "parent context" if any (otherwise, the text_plane context is used, or None) The rendering part include yielding the proper position of each rendering character,as contexts convey also text printing direction and marks can not only push a new printing direction, but also "teleport" the rendering point for the next character altogether. """ self.text = text self.mark_sequence = mark_sequence self.parent_context = context self._last_index_processed = None self.context = Context() self.text_plane = text_plane self.starting_point = V2(starting_point) if starting_point else V2(0, 0) self.current_position = self.starting_point self._sanity_counter = 0 self.locals = threading.local() if isinstance(text, GraphemeIter): self.cooked_text = text else: new_text = self.cooked_text = GraphemeIter(text) # adjust mark items to match graphemes instead of characters: sorted_old_keys = sorted(key for key in mark_sequence.keys() if isinstance(key, int)) new_keys = {old_key: new_key for old_key, new_key in zip( sorted_old_keys, new_text.iter_cooked_indexes(sorted_old_keys) )} self.mark_sequence = {new_keys.get(old_key, old_key): value for old_key, value in mark_sequence.items()}
def load_data(self, file_or_path, size=None): """Will load data from an image file. Size parameter is ignored """ if isinstance(file_or_path, PILImage.Image): img = file_or_path else: img = PILImage.open(file_or_path) if self.kwargs.get("auto_scale", True): scr = self.kwargs.get("screen", None) pixel_ratio = self.kwargs.get("pixel_ratio", 2) size = V2(scr.get_size() - (1, 1) if scr else (80, 12)) img_size = V2(img.width, img.height) if size.x < img_size.x or size.y < img_size.y: ratio_x = size.x / img_size.x ratio_y = (size.y / img_size.y) * pixel_ratio if ratio_x > ratio_y: size = V2( size.x, min((img_size.y * ratio_x / pixel_ratio), size.y - 1)) else: size = V2(img_size * ratio_y, size.y / pixel_ratio) img = img.resize(size.as_int, PILImage.BICUBIC) self.width, self.height = img.width, img.height if img.mode in ("L", "P", "I"): img = img.convert("RGB") elif img.mode in ("LA", "PA"): img = img.convert("RGBA") self.data = img
def load_data(self, file_or_path, size=None): """Will load data from an image file using PIL, Image is re-scaled to self.size if that is not None. """ if isinstance(file_or_path, PILImage.Image): img = file_or_path else: img = PILImage.open(file_or_path) if size is not None: pixel_ratio = 1 size = V2(size) - (1, 1) img_size = V2(img.width, img.height) if size.x < img_size.x or size.y < img_size.y: ratio_x = size.x / img_size.x ratio_y = (size.y / img_size.y) * pixel_ratio if ratio_x > ratio_y: size = V2( size.x, min((img_size.y * ratio_x / pixel_ratio), size.y - 1)) else: size = V2(img_size * ratio_y, size.y / pixel_ratio) img = img.resize(size.as_int, PILImage.BICUBIC) self.width, self.height = img.width, img.height if img.mode in ("L", "P", "I"): img = img.convert("RGB") elif img.mode in ("LA", "PA"): img = img.convert("RGBA") self.data = img
def moveto(self, pos, file=None): """Writes ANSI Sequence to position the text cursor Args: - pos (2-sequence): screen coordinates, (0, 0) being the top-left corner. Please note that ANSI commands count screen coordinates from 1, while in this project, coordinates start at 0 to conform to graphic display expected behaviors """ pos = V2(pos) if pos != (0, 0) and pos == self.__class__.last_pos: return if self.absolute_movement: self.CSI(f"{pos.y + 1};{pos.x + 1}H", file=file) else: if self.__class__.last_pos and pos.x == 0 and pos.y == self.__class__.last_pos.y + 1: self._print("\n", file=file) else: if not self.__class__.last_pos: self.__class__.last_pos = V2(0,0) delta_x = pos.x - self.__class__.last_pos.x delta_y = pos.y - self.__class__.last_pos.y if delta_x > 0: self.right(delta_x, file=file) elif delta_x < 0: self.left(-delta_x, file=file) if delta_y > 0: self.down(delta_y, file=file) elif delta_y < 0: self.up(-delta_y, file=file) self.__class__.last_pos = pos
def bezier(self, pos1, pos2, pos3, pos4): """Draws a bezier curve given the control points Args: pos1 (2-sequence): Fist control point pos2 (2-sequence): Second control point pos3 (2-sequence): Third control point pos4 (2-sequence): Fourth control point """ pos1 = V2(pos1) pos2 = V2(pos2) pos3 = V2(pos3) pos4 = V2(pos4) x, y = pos1 t = 0 step = 1 / (abs(pos4 - pos3) + abs(pos3 - pos2) + abs(pos2 - pos1)) self.set((x, y)) while t <= 1.0: x, y = pos1 * (1 - t)**3 + pos2 * 3 * (1 - t)**2 * t + pos3 * 3 * ( 1 - t) * t**2 + pos4 * t**3 self.set((round(x), round(y))) t += step
def line(self, pos1, pos2, erase=False): """Draws a straight line connecting both coordinates. Args: - pos1 (2-tuple): starting coordinates - pos2 (2-tuple): ending coordinates - erase (bool): Whether to draw (set) or erase (reset) pixels. Public call to draw an arbitrary line using character blocks on the terminal. The color line is defined in the passed parameter or from the context. """ op = self.reset if erase else self.set pos1 = V2(pos1) pos2 = V2(pos2) op(pos1) max_manh = max(abs(pos2.x - pos1.x), abs(pos2.y - pos1.y)) if max_manh == 0: return step_x = (pos2.x - pos1.x) / max_manh step_y = (pos2.y - pos1.y) / max_manh total_manh = 0 while total_manh < max_manh: pos1 += (step_x, step_y) total_manh += max(abs(step_x), abs(step_y)) op(pos1.as_int)
def match(self, sequence): # The ANSI sequence for a mouse event in mode 1006 is '<ESC>[B;Col;RowM' (last char is 'm' if button-release) m = re.match( r"\x1b\[\<(?P<button>\d+);(?P<column>\d+);(?P<row>\d+)(?P<press>[Mm])", sequence) if not m: return None params = m.groupdict() pressed = params["press"] == "M" button = _button_map.get(int(params["button"]) & (~0x20), None) moving = bool(int(params["button"]) & 0x20) col = int(params["column"]) - 1 row = int(params["row"]) - 1 click_event = event = None # TBD: check for different buttons in press events and send combined button events if moving: event = Event(EventTypes.MouseMove, pos=V2(col, row), buttons=button) elif pressed: ts = time.time() event = Event(EventTypes.MousePress, pos=V2(col, row), buttons=button, time=ts) self.last_press = ( ts, button, ) else: ts = time.time() event = Event(EventTypes.MouseRelease, pos=V2(col, row), buttons=button) if ts - self.last_press[ 0] < self.CLICK_THRESHOLD and button == self.last_press[1]: Event(EventTypes.MouseClick, pos=V2(col, row), buttons=button, time=ts) if ts - self.last_click[ 0] < self.DOUBLE_CLICK_THRESHOLD and button == self.last_click[ 1]: Event(EventTypes.MouseDoubleClick, pos=V2(col, row), buttons=button, time=ts) self.last_click = ( ts, button, ) return event
def __getitem__(self, index): roi = self.roi if isinstance(index, Rect): return ShapeView( self.original, Rect(V2.max(roi.c1, (roi.c1 + index.c1)), V2.min((roi.c1 + index.c2), roi.c2))) if not 0 <= index[0] < roi.width or not 0 <= index[1] < roi.bottom: raise IndexError(f"Value out of limits f{roi.width_height}") return self.original[roi.c1 + index]
def new_line_start(self, index, direction): pos = V2(index) direction = V2(direction) r = Rect(self.text_plane.size ) while True: pos, direction, flow_changed, position_is_used = self.move_along_marks(pos, direction) if flow_changed: return pos, direction if not pos in r: return pos, direction
def __init__(self): self.active_unicode_effects = Effects.none self.__class__.last_pos = V2(0, 0) self.next_pos = V2(0, 0) self.current_foreground = None self.current_background = None self.current_effects = None self.next_foreground = Color((0, 0, 0)) self.next_background = Color((255, 255, 255)) self.next_effects = Effects.none self.tag_is_open = False
def clear(self, wet_run=True): """Resets internal data, context parameters and clears the screen Args: - wet_run (bool): Whether to physically clear the screen or not Resets internal data and context parameters. The "self.data" and "self.color_data" structures are where the current character and attributes for each position are kept as characters actually just printed on the terminal can't be "read" back. Context foreground, background and direction are reset. In default operation, commands to clear the actual terminal and hide the cursor is also issued - the ``.clear_screen`` attribute controls that if ``.clear`` is being called as part of entering the screen context. """ self.context.last_pos = V2(0, 0) self.__class__.last_color = None self.__class__.last_background = None with self.lock: if wet_run: self.commands.clear() self.data.clear() else: self.data.clear(transparent=True) self.data.dirty_set() self.commands.cursor_hide()
def __init__(self, attributes=None, pop_attributes=None, moveto=None, rmoveto=None, color=None, foreground=None, background=None, effects=None, direction=None, transformer=None, new_line=None): self.attributes = attributes or {} if isinstance(pop_attributes, (set, Sequence)): pop_attributes = {name: None for name in pop_attributes} self.pop_attributes = pop_attributes self.moveto = moveto self.rmoveto = rmoveto for parameter in "color foreground background effects direction transformer new_line".split(): if locals()[parameter] is None: continue value = locals()[parameter] if parameter == "color": parameter = "foreground" if parameter == "transformer": parameter = "pretransformer" if parameter in ("foreground", "background") and not isinstance(value, Color): value = Color(value) if parameter == "effects" and not (isinstance(value, Effects) or value is TRANSPARENT): sep = "," if "," in value else "|" if "|" in value else " " effects = [e.strip() for e in value.split(sep) if e] value = Effects.none for effect in effects: value |= Effects.__members__[effect.lower()] if parameter == "direction" and isinstance(value, str): value = V2(value) self.attributes[parameter] = value self.affects_text_flow = bool(self.moveto or self.rmoveto or self.attributes.get("direction") or new_line)
def draw_border(self, transform=_bordersentinel, context=None, pad_level=1): """Draws an existing border, without changing the shape pattern call this just to redraw the border; A new border should be created by calling "add_border" """ if transform is _bordersentinel: transform = getattr(self, "_last_border_transform", None) elif transform != None: self._last_border_transform = transform size = self.size + (1, 1) * pad_level * 2 + (1, 1) border_shape = shape(size) if context: border_shape.context = context else: border_shape.context = self.owner.context with border_shape.context as context: border_shape.draw.rect((0, 0), (size)) if transform: context.transformers.append(transform) self.owner.draw.blit( V2(self.pad_left, self.pad_top) - (pad_level, pad_level), border_shape)
def bake(self, shape, target=None, offset=(0, 0)): """Apply the transformation stack for each pixel in the given shape Args: - shape: Source shape object to be processed - target [Optional]: optional target where final pixels are blitted into. If target is not given, 'shape' is modified inplace. Defaults to None. - offset: pixel-offset to blit the data to. Most useful with the target option. Returns: the affected Shape object """ from terminedia.image import FullShape if target: source = shape else: # Creates a copy of all data channels, sans sprites neither transformers: source = FullShape.promote(shape) target = shape # if target is shape, bad things will happen for some transformers - specially Kernel based transforms offset = V2(offset) for pos, pixel in source: target[pos + offset] = self.process(source, pos, pixel) return target
def build_args(channel, signature): nonlocal transformer, pixel, values, ch_num args = {} for parameter in signature: if parameter == "self": args["self"] = transformer elif parameter == "value": args["value"] = values[ch_num] elif parameter in Transformer.channels and parameter != "pixel": args[parameter] = getattr(pixel, parameter if parameter != "char" else "value") elif parameter == "pos": args["pos"] = V2(pos) elif parameter == "pixel": args["pixel"] = pixel elif parameter == "source": args["source"] = source elif parameter == "tick": args["tick"] = get_current_tick() elif parameter == "context": args["context"] = source.context elif hasattr(transformer, parameter): # Allows for custom parameters that can be made available # for specific uses of transformers. # (ex.: 'sequence_index' for transformers inlined in rich-text rendering) args[parameter] = getattr(transformer, parameter) return args
def moveto(self, pos, file=None): """Writes ANSI Sequence to position the text cursor Args: - pos (2-sequence): screen coordinates, (0, 0) being the top-left corner. Please note that ANSI commands count screen coordinates from 1, while in this project, coordinates start at 0 to conform to graphic display expected behaviors """ pos = V2(pos) if pos != (0, 0) and pos == self.__class__.last_pos: return # x, y = pos self.CSI(f'{pos.y + 1};{pos.x + 1}H', file=file) self.__class__.last_pos = V2(pos)
def __init__(self, size=(), clear_screen=True): if not size: #: Set in runtime to a method to retrieve the screen width, height. #: The class is **not** aware of terminal resizings while running, though. self.get_size = lambda: V2(os.get_terminal_size()) try: size = self.get_size() except OSError as error: if error.errno == 25: logger.error( "This terminal type does not allow guessing screen size." "Pass an explicit (cols, rows) size when instantiating {self.__class__}" ) raise else: self.get_size = lambda: V2(size) #: Namespace to configure drawing and printing color and other parameters. #: Currently, the attributes that are used from here are #: ``color``, ``background``, ``direction``, ``effects`` and ``char``. self.context = Context() #: Namespace for drawing methods, containing an instance of the :any:`Drawing` class self.draw = Drawing(self.set_at, self.reset_at, self.get_size, self.context) self.width, self.height = self.size = size #: Namespace to allow high-resolution drawing using a :any:`HighRes` instance #: One should either use the public methods in HighRes or the methods on the #: :any:`Drawing` instance at ``Screen.high.draw`` to do 1/4 block pixel #: manipulation. self.high = HighRes(self) self.braille = HighRes(self, block_class=BrailleChars, block_width=2, block_height=4) self.text = terminedia.text.Text(self) #: Namespace for low-level Terminal commands, an instance of :any:`JournalingScreenCommands`. #: This attribute can be used as a context manager to group #: various screen operations in a single block that is rendered at once. self.commands = JournalingScreenCommands() self.clear_screen = clear_screen self.data = FullShape.new((self.width, self.height)) # Synchronize context for data and screen painting. self.data.context = self.context
def moveto(self, pos, file=None): """Set internal state so that next character rendering is at the new coordinates; Args: - pos (2-sequence): screen coordinates, (0, 0) being the top-left corner. """ self.next_pos = V2(pos)
def __setitem__(self, pos, value): """Writes character data at pos Args: - pos (2-sequence): coordinate where to set character - value (length 1 string): Character to set. This is mostly used internally by all other drawing and printing methods, although it can be used directly, by using Python's object-key notation with ``[ ]`` and assignment. All text or graphics that go to the terminal *are directed through this method* - it is a "single point" where all data is sent - and any user code that writes to the terminal with a Screen class should use this method. Valus set on Screen are imediately updated on the screen. To issue a command batch that should be updated at once, use the Screen.commands attribute as a context manager: (`with sc.commands: ...code with lots of drawing calls ... `) If it is desired to draw/write in an in-memory buffer in order to update everything at once, one can issue the drawing class to affect the Screen.data attribute instead of Screen directly. The Screen contents can be updated by calling Screen.update afterwards. `Screen.data` is a terminedia.FullShape object with a .draw, .high and .text interfaces offering the same APIs available to Screen. """ if isinstance(value, str) and len(value) > 1: # Redirect strings through the text machinery. # it will separate each char in a cell, take care # of double chars, embedded attributes and so on self.text[1][pos] = value return cls = self.__class__ with self.lock: # Force underlying shape machinnery to apply context attributes and transformations: if value != _REPLAY: self.data[pos] = value pixel = self.data[pos] update_colors = (cls.last_color != pixel.foreground or cls.last_background != pixel.background or cls.last_effects != pixel.effects) if self.root_context.interactive_mode and time.time( ) - self._last_setitem > 0.1: update_colors = True self.commands.__class__.last_pos = None self._last_setitem = time.time() if update_colors: colors = pixel.foreground, pixel.background, pixel.effects self.commands.set_colors(*colors) cls.last_color = pixel.foreground cls.last_background = pixel.background cls.last_effects = pixel.effects if pixel.value not in (CONTINUATION, TRANSPARENT): self.commands.print_at(pos, pixel.value) self.context.last_pos = V2(pos)
def __setitem__(self, pos, value): """ Values set for each pixel are: character - only spaces (0x20) or "non-spaces" are taken into account for PalettedShape """ pos = V2(pos) self.dirty_mark_pixel(pos) type_ = self.PixelCls.capabilities.value_type self.raw_setitem(pos, type_(value))
def _clear_owner(self): # clear the inner contents of the owner when reflowing text, respecting padding size = self.size corner1 = V2(self.pad_left, self.pad_top) corner2 = corner1 + size self.owner[corner1.x:corner2.x, corner1.y:corner2.y].draw.fill(char=" ")
def concat(self, *others, direction=Directions.RIGHT, **kwargs): """Concatenates two given shapes side by side into a larger shape. Args: - other (Shape): Other shape to be concatenated. - direction (V2): Side which will be "enlarged" and on which the other shape will be placed. Most usefull values are Directions.RIGHT and Directions.DOWN - **kwargs: are passed down to the "new" constructor of the resulting shape. Creates a new shape combining two or more other shapes. If Shape _allowed_types differ, the logic in Drawing.blit will try to cast pixels to the one used in self. """ shapes = (self, ) + others direction = V2(direction) h_size = abs(direction.x) * sum(s.width for s in shapes) v_size = abs(direction.y) * sum(s.height for s in shapes) new_size = V2( max(h_size, max(s.width for s in shapes)), max(v_size, max(s.height for s in shapes)), ) new_shape = self.__class__.new(new_size, **kwargs) d = direction offset = V2(0 if d.x >= 0 else new_size.x, 0 if d.y >= 0 else new_size.y) # blit always take the top-left offset corner # so, depending on direction of concatenation, # offset have to be computed before or after blitting. for s in shapes: offset += ( int(s.width * d.x if d.x < 0 else 0), int(s.height * d.y if d.y < 0 else 0), ) new_shape.draw.blit(offset, s) offset += ( int(s.width * d.x if d.x >= 0 else 0), int(s.height * d.y if d.y >= 0 else 0), ) return new_shape
def _process_to(self, index): if self._last_index_processed is None and index == 0: self.current_position = self.starting_point elif ( self._last_index_processed is None or index != self._last_index_processed + 1 ): return self._reprocess_from_start(index) if self.locals.on_rendering_skipped_positions: skipped = self.locals.on_rendering_skipped_positions[:] self.locals.on_rendering_skipped_positions.clear() else: skipped = () for mark_here, mark_origin in self.marks.get_full(index, self.current_position, skipped=skipped): mark_here.context = self.context mark_here.pos = self.current_position if mark_here.attributes or mark_here.pop_attributes: self._context_push(mark_here.attributes, mark_here.pop_attributes, mark_origin, index) if mark_here.moveto: mtx = mark_here.moveto[0] mty = mark_here.moveto[1] mtx = ( self.current_position.x if mtx is RETAIN_POS else mtx.evaluate(self.text_plane.size) if isinstance(mtx, RelativeMarkIndex) else mtx ) mty = ( self.current_position.y if mty is RETAIN_POS else mty.evaluate(self.text_plane.size) if isinstance(mty, RelativeMarkIndex) else mty ) self.current_position = V2(mtx, mty) if mark_here.attributes.get("new_line", None): self.current_position, self.context.direction = self.marks.new_line_start(self.current_position, self.context.direction) if mark_here.rmoveto: self.current_position += V2(mark_here.rmoveto) self._last_index_processed = index return self.context
def at_parent(self, pos): """Get the equivalent, rounded down, coordinates, at the parent object. Args: - pos (2-sequence): screen coordinates, (0, 0) being the top-left corner. Returns: - V2 object with the equivalent object at the parent space. """ return V2(pos[0] // self.block_width, pos[1] // self.block_height)
def window_change_handler(signal_number, frame): """Called as a signal to terminal-window resize It is set as a handler on terminedia.Screen instantiation, and will automatically add window-resize events on terminedia event system. """ new_size = V2(os.get_terminal_size()) Event(EventTypes.TerminalSizeChange, size=new_size, dispatch=True)
def _get_drawing(self): from terminedia.drawing import Drawing # The 'type(self).__setitem__` pattern ensures __setitem__ is called on the proxy, # not on the proxied object. return Drawing( set_fn=lambda pos: type(self).__setitem__(self, pos, self.context. char), reset_fn=lambda pos: type(self).__setitem__(self, pos, EMPTY), size_fn=lambda: V2(self.width, self.height), context=self.context)
def abs_get(self, index, default=None): """Usually negative values for indexes coordinates will subtract those values from the width or height of the textplane, so "-1" is actually at "WIDTH - 1" == last column. But sometimes actually need to get the mark at the [-1] hard index. When that is needed, the mark should be retrieved through here, rather than through __getitem__ directly. For setting, since negative indexes will not depend on the width and height of the text plane (they are on the opposite side of these dynamic indexes: column -1 is the equivalent on the left-side of column WIDTH + 1 on the right side), assignment of marks can be done directly on the "self.data" attribute """ if index[0] < 0: index = V2(index[0] - self.text_plane.width, index[1]) if index[1] < 0: index = V2(index[0], index[1] - self.text_plane.height) return self.get(index, default)
def _render_using_screen(self, output, backend): from terminedia.screen import Screen if output is None: file = StringIO() else: file = output sc = Screen(size=V2(self.width, self.height), backend=backend) if backend == "ANSI": # generate a relocatable image sc.commands.__class__.last_pos = V2(0, 0) sc.commands.absolute_movement = False # Starts recording all image operations on the internal journal sc.commands.__enter__() sc.blit((0, 0), self) # Ends journal-recording, but without calling __exit__ # which does not allow passing an external file. sc.commands.stop_journal() # Renders all graphic ops as ANSI sequences + unicode into file: sc.commands.replay(output)