From cee7862b8beb93a6e1445fd920345c7d902b0ee8 Mon Sep 17 00:00:00 2001 From: Peter Hinch Date: Tue, 7 Sep 2021 10:31:25 +0100 Subject: [PATCH] writer.py Simpler faster color code. Requires fw V1.17. --- writer/WRITER.md | 42 +-- writer/old_versions/writer_fw_compatible.py | 342 ++++++++++++++++++++ writer/{ => old_versions}/writer_minimal.py | 0 writer/writer.py | 68 +--- 4 files changed, 373 insertions(+), 79 deletions(-) create mode 100644 writer/old_versions/writer_fw_compatible.py rename writer/{ => old_versions}/writer_minimal.py (100%) diff --git a/writer/WRITER.md b/writer/WRITER.md index 42af2ca..cdd6000 100644 --- a/writer/WRITER.md +++ b/writer/WRITER.md @@ -76,10 +76,15 @@ The module has the following features: Note that these changes have significantly increased code size. On the ESP8266 it is likely that `writer.py` will need to be frozen as bytecode. The original -very simple version still exists as `writer_minimal.py`. +very simple version still exists as `old_versions/writer_minimal.py`. ## 1.1 Release Notes +V0.5.0 Sep 2021 +With the release of firmware V1.17, color display now requires this version. +This enabled the code to be simplified. For old firmware V0.4.3 is available as +`old_versions/writer_fw_compatible.py`. + V0.4.3 Aug 2021 Supports fast rendering of glyphs to color displays (PR7682). See [Performance](./WRITER.md#223-performance). @@ -110,8 +115,6 @@ shows how to drive color displays using the `CWriter` class. 3. `writer_demo.py` Demo using a 128*64 SSD1306 OLED display. Import to see usage information. 4. `writer_tests.py` Test/demo scripts. Import to see usage information. - 5. `writer_minimal.py` A minimal version for highly resource constrained - devices. Sample fonts: 1. `freesans20.py` Variable pitch font file. @@ -119,6 +122,12 @@ Sample fonts: 3. `font10.py` Smaller variable pitch fonts. 4. `font6.py` +Old versions (in `old_versions` directory): + 1. `writer_minimal.py` A minimal version for highly resource constrained + devices. + 2. `writer_fw_compatible.py` V0.4.3. Color display will run on firmware + versions < 1.17. + ## 1.4 Fonts Python font files should be created using `font-to-py.py` using horizontal @@ -145,7 +154,7 @@ ultra low power monochrome display. The `Writer` class provides fast rendering to monochrome displays using bit blitting. The `CWriter` class is a subclass of `Writer` to support color -displays which can now offer comparable performance (see below). +displays which now offers comparable performance (see below). Multiple screens are supported. On any screen multiple `Writer` or `CWriter` instances may be used, each using a different font. A class variable holds the @@ -264,6 +273,9 @@ This takes the following args: 4. `bgcolor=None` Background color. If `None` a monochrome display is assumed. 5. `verbose=True` If `True` the constructor emits console printout. +The constructor checks for suitable firmware and also for a compatible device +driver: an `OSError` is raised if these are absent. + ### 2.2.2 Methods All methods of the base class are supported. Additional method: @@ -281,24 +293,12 @@ rendered in foreground color on background color (or reversed if `invert` is A firmware change [PR7682](https://github.com/micropython/micropython/pull/7682) enables a substantial improvement to text rendering speed on color displays. -This is in daily builds and will be incorporated in V1.17. The initialisation -code checks for suitable firmware and also for a compatible device driver. If -these are absent the old slower method of rendering is used. +This was incorporated in firmware V1.17, and `writer.py` requires this or later +if using a color display. -The module has a `fast_mode` variable which is set `True` on import if the -firmware supports fast rendering. User code should treat this as read-only. The -value of this is meaningless for monochrome displays which always render fast. - -If the `verbose` constructor arg is `True` a message will be printed on startup -indicating whether fast mode is in use. As above, this is meaningless for -monochrome displays. Possible reasons for it not being used: - * Firmware not recent enough. - * Display driver does not include a `palette` bound variable. - * `writer.py` not the current version. - -The gain in speed depends on the font size, increasing for larger fonts. -Numbers may be found in `writer.py` code comments. Typical 10-20 pixel fonts -see gains on the order of 5-10 times. +The gain in speed resulting from this firmware change depends on the font size, +increasing for larger fonts. Numbers may be found in `writer.py` code comments. +Typical 10-20 pixel fonts see gains on the order of 5-10 times. ###### [Contents](./WRITER.md#contents) diff --git a/writer/old_versions/writer_fw_compatible.py b/writer/old_versions/writer_fw_compatible.py new file mode 100644 index 0000000..f83eff2 --- /dev/null +++ b/writer/old_versions/writer_fw_compatible.py @@ -0,0 +1,342 @@ +# writer.py Implements the Writer class. +# Handles colour, word wrap and tab stops + +# V0.4.3 Aug 2021 Support for fast blit to color displays (PR7682). +# V0.4.0 Jan 2021 Improved handling of word wrap and line clip. Upside-down +# rendering no longer supported: delegate to device driver. +# V0.3.5 Sept 2020 Fast rendering option for color displays + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2019-2021 Peter Hinch + +# A Writer supports rendering text to a Display instance in a given font. +# Multiple Writer instances may be created, each rendering a font to the +# same Display object. + +# Timings were run on a pyboard D SF6W comparing slow and fast rendering +# and averaging over multiple characters. Proportional fonts were used. +# 20 pixel high font, timings were 5.44ms/467μs, gain 11.7 (freesans20). +# 10 pixel high font, timings were 1.76ms/396μs, gain 4.36 (arial10). + + +import framebuf +from uctypes import bytearray_at, addressof +from sys import implementation +import os + +__version__ = (0, 4, 3) + +def buildcheck(device): + if not hasattr(device, 'palette'): + return False + i0, i1, _ = implementation[1] + if i0 > 1 or i1 > 16: + return True + # After release of V1.17 require that build. Until then check for date. + # TODO simplify this once V1.17 is released. + try: + datestring = os.uname()[3] + date = datestring.split(' on')[1] + date = date.lstrip()[:10] + idate = tuple([int(x) for x in date.split('-')]) + return idate >= (2021, 8, 25) + except AttributeError: + return False + + +fast_mode = False # False for mono displays although actually these render fast + +class DisplayState(): + def __init__(self): + self.text_row = 0 + self.text_col = 0 + +def _get_id(device): + if not isinstance(device, framebuf.FrameBuffer): + raise ValueError('Device must be derived from FrameBuffer.') + return id(device) + +# Basic Writer class for monochrome displays +class Writer(): + + state = {} # Holds a display state for each device + + @staticmethod + def set_textpos(device, row=None, col=None): + devid = _get_id(device) + if devid not in Writer.state: + Writer.state[devid] = DisplayState() + s = Writer.state[devid] # Current state + if row is not None: + if row < 0 or row >= device.height: + raise ValueError('row is out of range') + s.text_row = row + if col is not None: + if col < 0 or col >= device.width: + raise ValueError('col is out of range') + s.text_col = col + return s.text_row, s.text_col + + def __init__(self, device, font, verbose=True): + self.devid = _get_id(device) + self.device = device + if self.devid not in Writer.state: + Writer.state[self.devid] = DisplayState() + self.font = font + if font.height() >= device.height or font.max_width() >= device.width: + raise ValueError('Font too large for screen') + # Allow to work with reverse or normal font mapping + if font.hmap(): + self.map = framebuf.MONO_HMSB if font.reverse() else framebuf.MONO_HLSB + else: + raise ValueError('Font must be horizontally mapped.') + if verbose: + fstr = 'Orientation: Horizontal. Reversal: {}. Width: {}. Height: {}.' + print(fstr.format(font.reverse(), device.width, device.height)) + print('Start row = {} col = {}'.format(self._getstate().text_row, self._getstate().text_col)) + self.screenwidth = device.width # In pixels + self.screenheight = device.height + self.bgcolor = 0 # Monochrome background and foreground colors + self.fgcolor = 1 + self.row_clip = False # Clip or scroll when screen fullt + self.col_clip = False # Clip or new line when row is full + self.wrap = True # Word wrap + self.cpos = 0 + self.tab = 4 + + self.glyph = None # Current char + self.char_height = 0 + self.char_width = 0 + self.clip_width = 0 + + def _getstate(self): + return Writer.state[self.devid] + + def _newline(self): + s = self._getstate() + height = self.font.height() + s.text_row += height + s.text_col = 0 + margin = self.screenheight - (s.text_row + height) + y = self.screenheight + margin + if margin < 0: + if not self.row_clip: + self.device.scroll(0, margin) + self.device.fill_rect(0, y, self.screenwidth, abs(margin), self.bgcolor) + s.text_row += margin + + def set_clip(self, row_clip=None, col_clip=None, wrap=None): + if row_clip is not None: + self.row_clip = row_clip + if col_clip is not None: + self.col_clip = col_clip + if wrap is not None: + self.wrap = wrap + return self.row_clip, self.col_clip, self.wrap + + @property + def height(self): # Property for consistency with device + return self.font.height() + + def printstring(self, string, invert=False): + # word wrapping. Assumes words separated by single space. + q = string.split('\n') + last = len(q) - 1 + for n, s in enumerate(q): + if s: + self._printline(s, invert) + if n != last: + self._printchar('\n') + + def _printline(self, string, invert): + rstr = None + if self.wrap and self.stringlen(string, True): # Length > self.screenwidth + pos = 0 + lstr = string[:] + while self.stringlen(lstr, True): # Length > self.screenwidth + pos = lstr.rfind(' ') + lstr = lstr[:pos].rstrip() + if pos > 0: + rstr = string[pos + 1:] + string = lstr + + for char in string: + self._printchar(char, invert) + if rstr is not None: + self._printchar('\n') + self._printline(rstr, invert) # Recurse + + def stringlen(self, string, oh=False): + sc = self._getstate().text_col # Start column + wd = self.screenwidth + l = 0 + for char in string[:-1]: + _, _, char_width = self.font.get_ch(char) + l += char_width + if oh and l + sc > wd: + return True # All done. Save time. + char = string[-1] + _, _, char_width = self.font.get_ch(char) + if oh and l + sc + char_width > wd: + l += self._truelen(char) # Last char might have blank cols on RHS + else: + l += char_width # Public method. Return same value as old code. + return l + sc > wd if oh else l + + # Return the printable width of a glyph less any blank columns on RHS + def _truelen(self, char): + glyph, ht, wd = self.font.get_ch(char) + div, mod = divmod(wd, 8) + gbytes = div + 1 if mod else div # No. of bytes per row of glyph + mc = 0 # Max non-blank column + data = glyph[(wd - 1) // 8] # Last byte of row 0 + for row in range(ht): # Glyph row + for col in range(wd -1, -1, -1): # Glyph column + gbyte, gbit = divmod(col, 8) + if gbit == 0: # Next glyph byte + data = glyph[row * gbytes + gbyte] + if col <= mc: + break + if data & (1 << (7 - gbit)): # Pixel is lit (1) + mc = col # Eventually gives rightmost lit pixel + break + if mc + 1 == wd: + break # All done: no trailing space + print('Truelen', char, wd, mc + 1) # TEST + return mc + 1 + + def _get_char(self, char, recurse): + if not recurse: # Handle tabs + if char == '\n': + self.cpos = 0 + elif char == '\t': + nspaces = self.tab - (self.cpos % self.tab) + if nspaces == 0: + nspaces = self.tab + while nspaces: + nspaces -= 1 + self._printchar(' ', recurse=True) + self.glyph = None # All done + return + + self.glyph = None # Assume all done + if char == '\n': + self._newline() + return + glyph, char_height, char_width = self.font.get_ch(char) + s = self._getstate() + np = None # Allow restriction on printable columns + if s.text_row + char_height > self.screenheight: + if self.row_clip: + return + self._newline() + oh = s.text_col + char_width - self.screenwidth # Overhang (+ve) + if oh > 0: + if self.col_clip or self.wrap: + np = char_width - oh # No. of printable columns + if np <= 0: + return + else: + self._newline() + self.glyph = glyph + self.char_height = char_height + self.char_width = char_width + self.clip_width = char_width if np is None else np + + # Method using blitting. Efficient rendering for monochrome displays. + # Tested on SSD1306. Invert is for black-on-white rendering. + def _printchar(self, char, invert=False, recurse=False): + s = self._getstate() + self._get_char(char, recurse) + if self.glyph is None: + return # All done + buf = bytearray(self.glyph) + if invert: + for i, v in enumerate(buf): + buf[i] = 0xFF & ~ v + fbc = framebuf.FrameBuffer(buf, self.clip_width, self.char_height, self.map) + self.device.blit(fbc, s.text_col, s.text_row) + s.text_col += self.char_width + self.cpos += 1 + + def tabsize(self, value=None): + if value is not None: + self.tab = value + return self.tab + + def setcolor(self, *_): + return self.fgcolor, self.bgcolor + +# Writer for colour displays. +class CWriter(Writer): + + + def __init__(self, device, font, fgcolor=None, bgcolor=None, verbose=True): + super().__init__(device, font, verbose) + global fast_mode + fast_mode = buildcheck(device) + if bgcolor is not None: # Assume monochrome. + self.bgcolor = bgcolor + if fgcolor is not None: + self.fgcolor = fgcolor + self.def_bgcolor = self.bgcolor + self.def_fgcolor = self.fgcolor + self._printchar = self._pchfast if fast_mode else self._pchslow + verbose and print('Render {} using fast mode'.format('is' if fast_mode else 'not')) + + def _pchfast(self, char, invert=False, recurse=False): + s = self._getstate() + self._get_char(char, recurse) + if self.glyph is None: + return # All done + buf = bytearray_at(addressof(self.glyph), len(self.glyph)) + fbc = framebuf.FrameBuffer(buf, self.clip_width, self.char_height, self.map) + palette = self.device.palette + palette.bg(self.fgcolor if invert else self.bgcolor) + palette.fg(self.bgcolor if invert else self.fgcolor) + + self.device.blit(fbc, s.text_col, s.text_row, -1, palette) + s.text_col += self.char_width + self.cpos += 1 + + def _pchslow(self, char, invert=False, recurse=False): + s = self._getstate() + self._get_char(char, recurse) + if self.glyph is None: + return # All done + char_height = self.char_height + char_width = self.char_width + clip_width = self.clip_width + + div, mod = divmod(char_width, 8) + gbytes = div + 1 if mod else div # No. of bytes per row of glyph + device = self.device + fgcolor = self.bgcolor if invert else self.fgcolor + bgcolor = self.fgcolor if invert else self.bgcolor + drow = s.text_row # Destination row + wcol = s.text_col # Destination column of character start + for srow in range(char_height): # Source row + for scol in range(clip_width): # Source column + # Destination column: add writer column + dcol = wcol + scol + gbyte, gbit = divmod(scol, 8) + if gbit == 0: # Next glyph byte + data = self.glyph[srow * gbytes + gbyte] + pixel = fgcolor if data & (1 << (7 - gbit)) else bgcolor + device.pixel(dcol, drow, pixel) + drow += 1 + if drow >= self.screenheight or drow < 0: + break + s.text_col += char_width + self.cpos += 1 + + def setcolor(self, fgcolor=None, bgcolor=None): + if fgcolor is None and bgcolor is None: + self.fgcolor = self.def_fgcolor + self.bgcolor = self.def_bgcolor + else: + if fgcolor is not None: + self.fgcolor = fgcolor + if bgcolor is not None: + self.bgcolor = bgcolor + return self.fgcolor, self.bgcolor diff --git a/writer/writer_minimal.py b/writer/old_versions/writer_minimal.py similarity index 100% rename from writer/writer_minimal.py rename to writer/old_versions/writer_minimal.py diff --git a/writer/writer.py b/writer/writer.py index f83eff2..7c9be14 100644 --- a/writer/writer.py +++ b/writer/writer.py @@ -1,6 +1,7 @@ # writer.py Implements the Writer class. # Handles colour, word wrap and tab stops +# V0.5.0 Sep 2021 Color now requires firmware >= 1.17. # V0.4.3 Aug 2021 Support for fast blit to color displays (PR7682). # V0.4.0 Jan 2021 Improved handling of word wrap and line clip. Upside-down # rendering no longer supported: delegate to device driver. @@ -24,27 +25,9 @@ from uctypes import bytearray_at, addressof from sys import implementation import os -__version__ = (0, 4, 3) +__version__ = (0, 5, 0) -def buildcheck(device): - if not hasattr(device, 'palette'): - return False - i0, i1, _ = implementation[1] - if i0 > 1 or i1 > 16: - return True - # After release of V1.17 require that build. Until then check for date. - # TODO simplify this once V1.17 is released. - try: - datestring = os.uname()[3] - date = datestring.split(' on')[1] - date = date.lstrip()[:10] - idate = tuple([int(x) for x in date.split('-')]) - return idate >= (2021, 8, 25) - except AttributeError: - return False - - -fast_mode = False # False for mono displays although actually these render fast +fast_mode = True # Does nothing. Kept to avoid breaking code. class DisplayState(): def __init__(self): @@ -202,7 +185,7 @@ class Writer(): break if mc + 1 == wd: break # All done: no trailing space - print('Truelen', char, wd, mc + 1) # TEST + # print('Truelen', char, wd, mc + 1) # TEST return mc + 1 def _get_char(self, char, recurse): @@ -272,19 +255,20 @@ class CWriter(Writer): def __init__(self, device, font, fgcolor=None, bgcolor=None, verbose=True): + if not hasattr(device, 'palette'): + raise OSError('Incompatible device driver.') + if implementation[1] < (1, 17, 0): + raise OSError('Firmware must be >= 1.17.') + super().__init__(device, font, verbose) - global fast_mode - fast_mode = buildcheck(device) if bgcolor is not None: # Assume monochrome. self.bgcolor = bgcolor if fgcolor is not None: self.fgcolor = fgcolor self.def_bgcolor = self.bgcolor self.def_fgcolor = self.fgcolor - self._printchar = self._pchfast if fast_mode else self._pchslow - verbose and print('Render {} using fast mode'.format('is' if fast_mode else 'not')) - def _pchfast(self, char, invert=False, recurse=False): + def _printchar(self, char, invert=False, recurse=False): s = self._getstate() self._get_char(char, recurse) if self.glyph is None: @@ -294,42 +278,10 @@ class CWriter(Writer): palette = self.device.palette palette.bg(self.fgcolor if invert else self.bgcolor) palette.fg(self.bgcolor if invert else self.fgcolor) - self.device.blit(fbc, s.text_col, s.text_row, -1, palette) s.text_col += self.char_width self.cpos += 1 - def _pchslow(self, char, invert=False, recurse=False): - s = self._getstate() - self._get_char(char, recurse) - if self.glyph is None: - return # All done - char_height = self.char_height - char_width = self.char_width - clip_width = self.clip_width - - div, mod = divmod(char_width, 8) - gbytes = div + 1 if mod else div # No. of bytes per row of glyph - device = self.device - fgcolor = self.bgcolor if invert else self.fgcolor - bgcolor = self.fgcolor if invert else self.bgcolor - drow = s.text_row # Destination row - wcol = s.text_col # Destination column of character start - for srow in range(char_height): # Source row - for scol in range(clip_width): # Source column - # Destination column: add writer column - dcol = wcol + scol - gbyte, gbit = divmod(scol, 8) - if gbit == 0: # Next glyph byte - data = self.glyph[srow * gbytes + gbyte] - pixel = fgcolor if data & (1 << (7 - gbit)) else bgcolor - device.pixel(dcol, drow, pixel) - drow += 1 - if drow >= self.screenheight or drow < 0: - break - s.text_col += char_width - self.cpos += 1 - def setcolor(self, fgcolor=None, bgcolor=None): if fgcolor is None and bgcolor is None: self.fgcolor = self.def_fgcolor