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()