edit.pyΒΆ

  1#!/usr/bin/env python
  2#
  3# Urwid example lazy text editor suitable for tabbed and format=flowed text
  4#    Copyright (C) 2004-2009  Ian Ward
  5#
  6#    This library is free software; you can redistribute it and/or
  7#    modify it under the terms of the GNU Lesser General Public
  8#    License as published by the Free Software Foundation; either
  9#    version 2.1 of the License, or (at your option) any later version.
 10#
 11#    This library is distributed in the hope that it will be useful,
 12#    but WITHOUT ANY WARRANTY; without even the implied warranty of
 13#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 14#    Lesser General Public License for more details.
 15#
 16#    You should have received a copy of the GNU Lesser General Public
 17#    License along with this library; if not, write to the Free Software
 18#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 19#
 20# Urwid web site: https://urwid.org/
 21
 22"""
 23Urwid example lazy text editor suitable for tabbed and flowing text.
 24
 25Features:
 26- custom list walker for lazily loading text file
 27
 28Usage:
 29edit.py <filename>
 30
 31"""
 32
 33from __future__ import annotations
 34
 35import sys
 36import typing
 37
 38import urwid
 39
 40
 41class LineWalker(urwid.ListWalker[int, urwid.Edit]):
 42    """ListWalker-compatible class for lazily reading file contents."""
 43
 44    def __init__(self, name: str) -> None:
 45        """Initialize with `name` opened for lazy line-by-line reading, no lines loaded yet."""
 46        # do not overcomplicate example
 47        self.file: typing.TextIO | None = open(  # noqa: SIM115  # pylint: disable=consider-using-with
 48            name,
 49            encoding="utf-8",
 50        )
 51        self.lines: list[urwid.Edit] = []
 52        self.focus = 0
 53
 54    def __del__(self) -> None:
 55        """Close the open file, if any."""
 56        if self.file is not None:
 57            self.file.close()
 58
 59    def get_focus(self) -> tuple[urwid.Edit, int] | tuple[None, None]:
 60        """Return the widget and position for the current focus."""
 61        return self._get_at_pos(self.focus)
 62
 63    def set_focus(self, focus: int) -> None:
 64        """Set the focus position."""
 65        self.focus = focus
 66        self._modified()
 67
 68    def get_next(self, position: int) -> tuple[urwid.Edit, int] | tuple[None, None]:
 69        """Return the widget and position after the given position."""
 70        return self._get_at_pos(position + 1)
 71
 72    def get_prev(self, position: int) -> tuple[urwid.Edit, int] | tuple[None, None]:
 73        """Return the widget and position before the given position."""
 74        return self._get_at_pos(position - 1)
 75
 76    def read_next_line(self) -> str:
 77        """Read another line from the file.
 78
 79        :raises AssertionError: if called after the file has already been closed.
 80        """
 81        assert self.file is not None, "read past end of file"  # noqa: S101  # "assert" is OK in examples
 82
 83        next_line = self.file.readline()
 84
 85        if not next_line or next_line[-1:] != "\n":
 86            # no newline on last line of file
 87            self.file = None
 88        else:
 89            # trim newline characters
 90            next_line = next_line[:-1]
 91
 92        edit = urwid.Edit("", next_line, allow_tab=True, expand_tabs=True)
 93        edit.edit_pos = 0
 94        self.lines.append(edit)
 95
 96        return next_line
 97
 98    def _get_at_pos(self, pos: int) -> tuple[urwid.Edit, int] | tuple[None, None]:
 99        """Return a widget for the line number passed."""
100        if pos < 0:
101            # line 0 is the start of the file, no more above
102            return None, None
103
104        if len(self.lines) > pos:
105            # we have that line so return it
106            return self.lines[pos], pos
107
108        if self.file is None:
109            # file is closed, so there are no more lines
110            return None, None
111
112        assert pos == len(self.lines), "out of order request?"  # noqa: S101  # "assert" is OK in examples
113
114        self.read_next_line()
115
116        return self.lines[-1], pos
117
118    def split_focus(self) -> None:
119        """Divide the focus edit widget at the cursor location."""
120        focus = self.lines[self.focus]
121        pos = focus.edit_pos
122        edit = urwid.Edit("", focus.edit_text[pos:], allow_tab=True, expand_tabs=True)
123        focus.set_edit_text(focus.edit_text[:pos])
124        edit.edit_pos = 0
125        self.lines.insert(self.focus + 1, edit)
126
127    def combine_focus_with_prev(self) -> None:
128        """Combine the focus edit widget with the one above."""
129        above, _ = self.get_prev(self.focus)
130        if above is None:
131            # already at the top
132            return
133
134        focus = self.lines[self.focus]
135        above.set_edit_pos(len(above.edit_text))
136        above.set_edit_text(above.edit_text + focus.edit_text)
137        del self.lines[self.focus]
138        self.focus -= 1
139
140    def combine_focus_with_next(self) -> None:
141        """Combine the focus edit widget with the one below."""
142        below, _ = self.get_next(self.focus)
143        if below is None:
144            # already at bottom
145            return
146
147        focus = self.lines[self.focus]
148        focus.set_edit_text(focus.edit_text + below.edit_text)
149        del self.lines[self.focus + 1]
150
151
152class EditDisplay:
153    """Text editor application, tying the file walker to the urwid widget tree."""
154
155    palette: typing.ClassVar[list[tuple[str, str, str] | tuple[str, str, str, str]]] = [
156        ("body", "default", "default"),
157        ("foot", "dark cyan", "dark blue", "bold"),
158        ("key", "light cyan", "dark blue", "underline"),
159    ]
160
161    footer_text: typing.ClassVar[tuple[str, list[str | tuple[str, str]]]] = (
162        "foot",
163        [
164            "Text Editor    ",
165            ("key", "F5"),
166            " save  ",
167            ("key", "F8"),
168            " quit",
169        ],
170    )
171
172    def __init__(self, name: str) -> None:
173        """Initialize with the file at `name` loaded for editing."""
174        self.save_name = name
175        self.walker = LineWalker(name)
176        self.listbox: urwid.ListBox[int] = urwid.ListBox(self.walker)
177        self.footer = urwid.AttrMap(urwid.Text(self.footer_text), "foot")
178        self.view: urwid.Frame[
179            urwid.AttrMap[urwid.ListBox[int]],
180            None,
181            urwid.AttrMap[urwid.Text],
182        ] = urwid.Frame(urwid.AttrMap(self.listbox, "body"), footer=self.footer)
183
184    def main(self) -> None:
185        """Run the text editor application."""
186        self.loop = urwid.MainLoop(self.view, self.palette, unhandled_input=self.unhandled_keypress)
187        self.loop.run()
188
189    def unhandled_keypress(self, k: str | tuple[str, int, int, int]) -> bool | None:
190        """Last resort for keypresses.
191
192        :raises urwid.ExitMainLoop: :kbd:`f8` is pressed.
193        """
194        if k == "f5":
195            self.save_file()
196        elif k == "f8":
197            raise urwid.ExitMainLoop
198        elif k == "delete":
199            # delete at end of line
200            self.walker.combine_focus_with_next()
201        elif k == "backspace":
202            # backspace at beginning of line
203            self.walker.combine_focus_with_prev()
204        elif k == "enter":
205            # start new line
206            self.walker.split_focus()
207            # move the cursor to the new line and reset pref_col
208            self.loop.process_input(["down", "home"])
209        elif k == "right":
210            _w, pos = self.walker.get_next(self.walker.focus)
211            if pos is not None:
212                self.listbox.set_focus(pos, "above")
213                self.loop.process_input(["home"])
214        elif k == "left":
215            _w, pos = self.walker.get_prev(self.walker.focus)
216            if pos is not None:
217                self.listbox.set_focus(pos, "below")
218                self.loop.process_input(["end"])
219        else:
220            return None
221        return True
222
223    def save_file(self) -> None:
224        """Write the file out to disk."""
225        walk = self.walker
226        # collect the text already stored in edit widgets
227        lines = [edit.edit_text for edit in walk.lines]
228
229        # then the rest
230        while walk.file is not None:
231            lines.append(walk.read_next_line())
232
233        # write back to disk
234        with open(self.save_name, "w", encoding="utf-8") as outfile:
235            prefix = ""
236            for line in lines:
237                outfile.write(prefix + line)
238                prefix = "\n"
239
240
241def main() -> None:
242    """Open the file named on the command line and run the editor on it."""
243    try:
244        name = sys.argv[1]
245        # do not overcomplicate example
246        assert open(name, "ab")  # noqa: SIM115,S101  # pylint: disable=consider-using-with
247    except OSError:
248        sys.stderr.write(__doc__ or "")  # noqa: FURB143  # __doc__ is None under python -OO
249        return
250    EditDisplay(name).main()
251
252
253if __name__ == "__main__":
254    main()