code / btui

Lines2.2K C1.2K Python549 Markdown219 make156 Lua49
(248 lines)
1 #!/usr/bin/env python3
2 #
3 # bed - the BTUI editor
4 #
5 # This is a simple example program demonstrating a minimal text editor
6 # implemented with the Python BTUI bindings.
7 #
8 # Usage: ./bed.py [-d|--debug] <file>
9 # You can move around with arrow keys, PgUp/PgDn and make basic file edits.
10 # Save with Ctrl-S, Quit with Ctrl-Q or Ctrl-C
12 import sys
13 import btui
14 from collections import namedtuple
16 Vec2 = namedtuple('Vec2', 'x y')
18 def clamp(x, low, high):
19 if x < low: return low
20 elif x > high: return high
21 return x
23 class BedFile:
24 def __init__(self, bt, lines, pos=Vec2(0,0), size=None):
25 self.bt = bt
26 self.lines = lines
27 self.drawn = set()
28 self.numlen = len(str(len(self.lines)))
29 self.pos = pos
30 self.size = size if size else Vec2(self.bt.width-pos.x, self.bt.height-pos.y)
31 self.scroll = 0
32 self._cursor = Vec2(0, 0)
33 self.unsaved = False
35 @property
36 def cursor(self):
37 return self._cursor
39 @cursor.setter
40 def cursor(self, p):
41 x, y = p.x, p.y
42 y = clamp(y, 0, len(self.lines)-1)
43 x = clamp(x, 0, len(self.lines[y]))
44 self._cursor = Vec2(x, y)
46 def update_term_cursor(self):
47 self.bt.move(self.pos.x + self.numlen + 1 + self.cursor.x,
48 self.pos.y + self.cursor.y - self.scroll)
50 def set_scroll(self, newscroll):
51 newscroll = clamp(newscroll, 0, len(self.lines)-1 - (self.size.y-1))
52 if newscroll == self.scroll: return
53 delta = newscroll - self.scroll
54 self.bt.scroll(self.pos.y, self.pos.y + self.size.y - 1, delta)
55 self.scroll = newscroll
56 self.drawn &= set(range(self.scroll, self.scroll + (self.size.y-1)+1))
57 if self.cursor.y < newscroll:
58 self.cursor = Vec2(self.cursor.x, newscroll)
59 elif self.cursor.y > newscroll + (self.size.y-1):
60 self.cursor = Vec2(self.cursor.x, newscroll + (self.size.y-1))
62 def handle_input(self, key, mx=None, my=None):
63 if key == 'Space': key = ' '
64 if key == 'Tab': key = ' '
66 if key == 'Left':
67 self.cursor = Vec2(self.cursor.x - 1, self.cursor.y)
68 elif key == 'Right':
69 self.cursor = Vec2(self.cursor.x + 1, self.cursor.y)
70 elif key == 'Up':
71 self.cursor = Vec2(self.cursor.x, self.cursor.y - 1)
72 if self.cursor.y < self.scroll:
73 self.set_scroll(self.cursor.y)
74 elif key == 'Down':
75 self.cursor = Vec2(self.cursor.x, self.cursor.y + 1)
76 newscroll = clamp(self.scroll, self.cursor.y - (self.size.y-1), self.cursor.y)
77 if self.cursor.y > self.scroll + (self.size.y-1):
78 self.set_scroll(self.cursor.y - (self.size.y-1))
79 elif key == 'Home' or key == 'Ctrl-a':
80 self.cursor = Vec2(0, self.cursor.y)
81 elif key == 'End' or key == 'Ctrl-e':
82 self.cursor = Vec2(len(self.lines[self.cursor.y]), self.cursor.y)
83 elif key == 'Page Down' or key == 'Ctrl-d':
84 self.set_scroll(self.scroll + self.bt.height // 2)
85 elif key == 'Page Up' or key == 'Ctrl-u':
86 self.set_scroll(self.scroll - self.bt.height // 2)
87 elif key == 'Delete':
88 line = self.lines[self.cursor.y]
89 i = self.cursor.x
90 if i == len(line) and self.cursor.y < len(self.lines)-1:
91 line.extend(self.lines.pop(self.cursor.y+1))
92 self.drawn &= {d for d in self.drawn if d < self.cursor.y or d >= len(self.lines)+1}
93 self.unsaved = True
94 elif i < len(line):
95 del line[i:i+1]
96 self.drawn.discard(self.cursor.y)
97 self.unsaved = True
98 elif key == 'Backspace':
99 line = self.lines[self.cursor.y]
100 i = self.cursor.x
101 if i == 0 and self.cursor.y > 0:
102 self.lines.pop(self.cursor.y)
103 prevline = self.lines[self.cursor.y - 1]
104 self.cursor = Vec2(len(prevline), self.cursor.y-1)
105 prevline.extend(line)
106 self.drawn &= {d for d in self.drawn if d < self.cursor.y or d >= len(self.lines)+1}
107 self.unsaved = True
108 elif i > 0:
109 del line[i-1:i]
110 self.drawn.discard(self.cursor.y)
111 self.cursor = Vec2(self.cursor.x - 1, self.cursor.y)
112 self.unsaved = True
113 elif key == 'Enter':
114 line = self.lines[self.cursor.y]
115 before, after = line[:self.cursor.x], line[self.cursor.x:]
116 self.lines[self.cursor.y] = before
117 self.lines.insert(self.cursor.y+1, after)
118 self.drawn &= {d for d in self.drawn if d < self.cursor.y or d >= len(self.lines)}
119 self.cursor = Vec2(0, self.cursor.y + 1)
120 self.unsaved = True
121 elif key == 'Left release':
122 self.cursor = Vec2(mx-(self.numlen + 1), my - 1 + self.scroll)
123 elif key == 'Mouse wheel down':
124 self.set_scroll(self.scroll + 3)
125 elif key == 'Mouse wheel up':
126 self.set_scroll(self.scroll - 3)
127 elif key and (len(key) == 1 or key == ' '):
128 line = self.lines[self.cursor.y]
129 i = self.cursor.x
130 line[i:i] = bytearray(key, 'utf8')
131 self.cursor = Vec2(self.cursor.x + len(key), self.cursor.y)
132 self.drawn.discard(self.cursor.y)
133 self.unsaved = True
135 def render(self):
136 self.bt.hide_cursor()
137 for i in range(self.size.y):
138 lineno = self.scroll + i
139 if lineno in self.drawn: continue
140 y = self.pos.y + i
141 x = self.pos.x
142 self.bt.move(x, y)
143 if lineno >= len(self.lines):
144 self.bt.clear(btui.ClearType.LINE)
145 self.drawn.add(lineno)
146 continue
147 with self.bt.attributes("faint"):
148 self.bt.write(("{:>"+str(self.numlen)+"}").format(lineno + 1))
149 x += self.numlen + 1
150 self.bt.move(x, y)
151 self.bt.write_bytes(bytes(self.lines[lineno]))
152 self.bt.clear(btui.ClearType.RIGHT)
153 self.drawn.add(lineno)
155 class BED:
156 def __init__(self, bt, filename):
157 self.bt = bt
158 self.filename = filename
159 try:
160 lines = [bytearray(line.rstrip(b'\n')) for line in open(filename, 'rb').readlines()]
161 except FileNotFoundError:
162 lines = [bytearray(b'')]
163 self.file = BedFile(bt, lines, Vec2(0, 1), Vec2(bt.width, bt.height-2))
164 self.drawn = set()
165 self.renders = 0
167 def edit(self):
168 self.bt.set_cursor(btui.CursorType.BLINKING_BAR)
169 self.render()
170 key, mx, my = self.bt.getkey()
171 while True:
172 if key == 'Ctrl-c':
173 sys.exit(1)
174 elif key == 'Ctrl-q':
175 if self.file.unsaved:
176 self.confirm_save()
177 break
178 elif key == 'Ctrl-s':
179 self.save()
180 else:
181 self.file.handle_input(key, mx, my)
182 self.render()
183 key, mx, my = self.bt.getkey()
185 def confirm_save(self):
186 self.bt.move(0, self.bt.height)
187 with self.bt.attributes('bold'):
188 self.bt.write(f"Do you want to save {self.filename}? [y/n]")
189 key, mx, my = self.bt.getkey()
190 if key in ('y', 'Y'):
191 self.save()
192 return True
193 elif key in ('n', 'N', 'Ctrl-c', 'Escape', 'Ctrl-q'):
194 return False
196 def save(self):
197 with open(self.filename, 'wb') as f:
198 for line in self.file.lines:
199 f.write(line)
200 f.write(b'\n')
201 self.file.unsaved = False
203 def render(self):
204 self.renders += 1
206 k = 'title-modified' if self.file.unsaved else 'title'
207 if k not in self.drawn:
208 self.drawn -= {'title', 'title-modified'}
209 self.bt.move(0,0)
210 with self.bt.attributes("bold"):
211 self.bt.write(self.filename)
212 if self.file.unsaved:
213 with self.bt.attributes("dim"):
214 self.bt.write(" (modified)")
215 self.bt.clear(btui.ClearType.RIGHT)
216 self.drawn.add(k)
218 self.file.render()
220 # Just redraw this every time:
221 with self.bt.attributes("bold"):
222 self.bt.move(0, self.bt.height-1)
223 self.bt.write("Ctrl-Q to quit, Ctrl-S to save")
224 self.bt.clear(btui.ClearType.RIGHT)
225 with self.bt.attributes("faint"):
226 s = f"Line {self.file.cursor.y}, Col {self.file.cursor.x}, {self.renders} redraws"
227 self.bt.move(self.bt.width-len(s), self.bt.height-1)
228 self.bt.write(s)
230 self.file.update_term_cursor()
231 self.bt.show_cursor()
234 if __name__ == '__main__':
235 args = sys.argv[1:]
236 debug = False
237 if args and args[0] in ('-d', '--debug'):
238 args.pop(0)
239 debug = True
241 if not args:
242 print("Usage: bed.py [-d|--debug] file")
243 sys.exit(1)
245 filename = args[0]
246 with btui.open(debug=debug) as bt:
247 bed = BED(bt, filename)
248 bed.edit()