code / tomo-unicode

Lines41.6K Text41.0K Tomo583
3 others 91
Markdown81 make7 INI3
(432 lines)
1 # A unicode table viewer TUI
2 use btui
3 use <sys/wait.h>
4 use ./names.tm
5 use ./themes.tm
7 BINARY: "unicode"
8 HELP: "
9 `unicode` is a Tomo program to view information about the Unicode 3.1 standard
10 codepoints. The table viewer is an interactive text user interface with the
11 following controls:
13 q - Quit the program
14 j/k or up/down - Move up or down one entry
15 Ctrl+d/Ctrl+u - Move up or down one page
16 g/G - Move to top/bottom
17 Ctrl+c or y - Copy the text of an entry to the clipboard
18 u - Copy the codepoint of an entry (U+XXXX) to the clipboard
19 d - Copy the decimal codepoint of an entry to the clipboard
20 p - Exit and print the unicode entry to stdout
21 Ctrl+f or / - Search for text (enter to confirm)
22 n/N - Jump to next/previous search result
23 i - Toggle info panel
25 MANPAGE_DESCRIPTION: "
26 `unicode` is a Tomo program to view information about the Unicode 3.1 standard
27 codepoints. The table viewer is an interactive text user interface.
29 LICENSE: ./LICENSE.md
31 struct UnicodeBlock(first,last:Int32, description:Text)
32 UNICODE_BLOCKS : [UnicodeBlock] = UnicodeBlock.load_all()
34 func load_all(-> [UnicodeBlock])
35 C_code `
36 static const char unicode_blocks[] = {
37 #embed "../UnicodeBlocks.txt"
38 ,0,
39 };
41 blocks : &[UnicodeBlock] = &[]
42 for line in C_code:Text`Text$from_str(unicode_blocks)`.lines()
43 skip if line.length == 0 or line.starts_with("#")
44 sections := line.split(";")
45 range := sections[1]!.split("..")
46 low := Int32.parse(range[1]!, 16)!
47 high := Int32.parse(range[2]!, 16)!
48 block := UnicodeBlock(low, high, sections[2]!.trim())
49 blocks.insert(block)
50 assert blocks.length > 0
51 return blocks
53 func find(codepoint:Int32 -> UnicodeBlock?)
54 target := UnicodeBlock(codepoint, codepoint, "")
55 i := UnicodeBlock.UNICODE_BLOCKS.binary_search(func(b:&UnicodeBlock) b[] >= target) or return none
56 return UnicodeBlock.UNICODE_BLOCKS[i]
59 struct UnicodeEntry(
60 codepoint:Int32,
61 text:Text?=none,
62 name:Text="",
63 category:Text="",
64 combining_class:Text="",
65 bidi_class:Text="",
66 decomposition_mapping:Text="",
67 decimal_digit:Int?=none,
68 digit:Int?=none,
69 numeric:Int?=none,
70 mirrored:Bool=no,
71 unicode_1_name:Text?=none,
72 iso_comment:Text?=none,
73 simple_uppercase:Int32?=none,
74 simple_lowercase:Int32?=none,
75 simple_titlecase:Int32?=none,
77 func parse(text:Text -> UnicodeEntry?)
78 # For format details, see: https://www.unicode.org/L2/L1999/UnicodeData.html
79 items := text.split(";")
80 entry := UnicodeEntry(Int32.parse((items[1] or return none), 16) or return none)
81 entry.text = Text.from_utf32([entry.codepoint])
82 entry.name = items[2] or return none
83 entry.category = items[3] or return none
84 entry.combining_class = items[4] or return none
85 entry.bidi_class = items[5] or return none
86 entry.decomposition_mapping = items[6] or return none
87 junk : Text
88 entry.decimal_digit = Int.parse(items[7] or return none, remainder=&junk)
89 entry.digit = Int.parse(items[8] or return none, remainder=&junk)
90 entry.numeric = Int.parse(items[9] or return none, remainder=&junk)
91 entry.mirrored = items[10] == "Y"
92 entry.unicode_1_name = (if items[11]!.length > 0 then items[11]!)
93 entry.iso_comment = items[12]
94 entry.simple_uppercase = Int32.parse((items[13] or return none), 16, &junk)
95 entry.simple_lowercase = Int32.parse((items[14] or return none), 16, &junk)
96 entry.simple_titlecase = Int32.parse((items[15] or return none), 16, &junk)
97 return entry
99 func info(self:UnicodeEntry -> {Text:Text})
100 block := UnicodeBlock.find(self.codepoint)
102 return {
103 "Symbol": (if self.codepoint > 32 then self.text or "" else ""),
104 "Block": (if b := block then b.description else ""),
105 "UTF32": "$(self.codepoint.hex()) ($(self.codepoint))",
106 "UTF16": (if text := self.text then " ".join([u.hex() for u in text.utf16()]) ++ " (" ++ " ".join([Text(u) for u in text.utf16()]) ++ ")" else "")
107 "UTF8": (if text := self.text then " ".join([b.hex() for b in text.utf8()]) else "")
108 "Name": self.name,
109 "Unicode 1 name": self.unicode_1_name or "",
110 "Category": named(self.category, CATEGORY_NAMES),
111 "Combining class": named(self.combining_class, COMBINING_NAMES),
112 "Bidi class": named(self.bidi_class, BIDI_NAMES),
113 "Decomposition": named(self.decomposition_mapping, DECOMPOSITION_NAMES),
114 "Digit": (if d := self.digit then Text(d) else ""),
115 "Mirrored": Text(self.mirrored),
116 "ISO comment": self.iso_comment or "",
117 "Uppercase": (if u := self.simple_uppercase then Text.from_utf32([u])! else ""),
118 "Lowercase": (if l := self.simple_lowercase then Text.from_utf32([l])! else ""),
119 "Titlecase": (if t := self.simple_titlecase then Text.from_utf32([t])! else ""),
122 func draw(self:UnicodeEntry, y:Int, theme:Theme=Dark, highlighted=no)
123 columns := [
124 "$(
125 if theme == Theme.None and highlighted then ">" else " "
126 )U+$(self.codepoint.hex(digits=5, prefix=no))",
129 if text := self.text
130 if self.codepoint > 32
131 text
132 else
134 else
140 name := if self.name then self.name else "No name"
141 if desc := self.unicode_1_name
142 name ++= " "++desc
143 name.title()
147 styles := [
148 func() theme.row_codepoint(highlighted)
149 func() theme.row_character(highlighted)
150 func() theme.row_description(highlighted)
153 widths := [10, 6, 32]
155 x := 0
156 for i,column in columns
157 styles[i]!()
158 write(" $column ", ScreenVec2(x, y))
159 clear(Right)
160 x += widths[i]!
162 struct TableViewer(
163 entries:[Text],
164 _top:Int=1,
165 _cursor:Int=1,
166 quit:Bool=no,
167 show_info:Bool=yes,
168 search_start:Int?=none,
169 search:Text?=none,
170 message:Text?=none,
171 theme:Theme=Theme.Dark,
173 func draw(self:TableViewer)
174 size := get_size()
175 self.theme.header()
176 write(" Codepoint Symbol Description ", ScreenVec2(0,0))
177 clear(Right)
179 for y in (1).to(size.y - 1)
180 row := self._top + y - 1
181 entry := self.get_entry(row) or skip
182 entry.draw(y, theme=self.theme, highlighted=(row == self._cursor))
184 if self.show_info
185 if entry := self.get_entry()
186 info := entry.info()
187 height := info.length + 2
188 label_width := (_max_: k.width() for k in info.keys)!
189 value_width := (_max_: v.width() for v in info.values)! _max_ 50
190 width := label_width + 3 + value_width
191 top_left := ScreenVec2(size.x - width - 1, 1)
192 self.theme.box()
193 fill_box(top_left, ScreenVec2(width, height))
194 for i,label in info.keys
195 write(label, pos=top_left + ScreenVec2(label_width + 1, i), Right)
196 self.theme.box_details()
197 for i,value in info.values
198 write(value, pos=top_left + ScreenVec2(label_width + 2, i), Left)
200 if search := self.search
201 self.theme.search_label(self.search_start != none)
202 write(" Search: ", ScreenVec2(0, size.y-1))
203 self.theme.search_text(self.search_start != none)
204 write(" "++search)
205 clear(Right)
207 if message := self.message
208 self.theme.message_theme()
209 write(" $message ", ScreenVec2(size.x-1, size.y-1), Right)
210 clear(Right)
212 # Scroll bar
213 scroll_height := size.y-2
214 scroll_top := 1 + (self._top * scroll_height)/self.entries.length
215 scroll_bottom := 1 + ((self._top + scroll_height - 1) * scroll_height)/self.entries.length
216 self.theme.scroll_bg()
217 for y in (1).to(scroll_top-1, step=1)
218 write(" ", ScreenVec2(size.x-1, y))
219 self.theme.scroll_bar()
220 for y in (scroll_top).to(scroll_bottom, step=1)
221 write(" ", ScreenVec2(size.x-1, y))
222 self.theme.scroll_bg()
223 for y in (scroll_bottom+1).to(size.y-1, step=1)
224 write(" ", ScreenVec2(size.x-1, y))
226 flush()
228 func update(self:&TableViewer)
229 if self.search_start
230 self.update_search()
231 return
233 size := get_size()
234 mouse_pos := ScreenVec2(0, 0)
235 key := get_key(&mouse_pos)
236 when key
237 is "j", "Down"
238 self.move_cursor(1)
239 is "Mouse wheel down"
240 self.move_scroll(1)
241 is "k", "Up"
242 self.move_cursor(-1)
243 is "Mouse wheel up"
244 self.move_scroll(-1)
245 is "Left press", "Left drag"
246 if 1 <= mouse_pos.y and mouse_pos.y <= size.y-2
247 if mouse_pos.x >= size.x-1
248 self.set_cursor((self.entries.length * (mouse_pos.y - 1)) / (size.y - 2))
249 # Prevent spamming the console too much
250 sleep(0.01)
251 else if key == "Left press"
252 self.set_cursor(self._top + mouse_pos.y - 1)
253 is "g"
254 self.move_cursor(-self.entries.length)
255 is "G"
256 self.move_cursor(self.entries.length)
257 is "q"
258 self.quit = yes
259 is "Escape"
260 if self.search != none
261 self.search = none
262 else
263 self.quit = yes
264 is "Ctrl-d"
265 self.move_scroll(size.y/2)
266 is "Ctrl-u"
267 self.move_scroll(-size.y/2)
268 is "Ctrl-c", "y"
269 if entry := self.get_entry()
270 if text := entry.text
271 if copy_to_clipboard(text)
272 self.message = "Copied text!"
273 else
274 self.message = "Failed to copy to clipboard!"
275 is "p"
276 if entry := self.get_entry()
277 if text := entry.text
278 disable()
279 print(text)
280 exit()
282 is "u"
283 if entry := self.get_entry()
284 if copy_to_clipboard("U+$(entry.codepoint.hex())")
285 self.message = "Copied U+$(entry.codepoint.hex())!"
286 else
287 self.message = "Failed to copy to clipboard!"
288 is "d"
289 if entry := self.get_entry()
290 if copy_to_clipboard("$(entry.codepoint)")
291 self.message = "Copied $(entry.codepoint)!"
292 else
293 self.message = "Failed to copy to clipboard!"
294 is "i"
295 self.show_info = not self.show_info
296 is "/", "Ctrl-f"
297 self.search = ""
298 self.search_start = self._cursor
299 self.message = none
300 is "n"
301 if search := self.search
302 for offset in (0).to(self.entries.length-1)
303 index := (self._cursor + 1 + offset) mod1 self.entries.length
304 line := self.entries[index]!
305 if line.lower().has(search)
306 self.set_cursor(index)
307 stop
308 is "N"
309 if search := self.search
310 for offset in (self.entries.length-1).to(0)
311 index := (self._cursor - 1 + offset) mod1 self.entries.length
312 line := self.entries[index]!
313 if line.lower().has(search)
314 self.set_cursor(index)
315 stop
317 func get_entry(self:TableViewer, row:Int?=none -> UnicodeEntry?)
318 return UnicodeEntry.parse(self.entries[row or self._cursor] or return none)
320 func update_search(self:&TableViewer)
321 key := get_key()
322 search := self.search or ""
323 when key
324 is "Escape", "Ctrl-c"
325 self.search = none
326 self.search_start = none
327 return
328 is "Enter"
329 # keep self.search so we can use 'n' to find it later
330 self.search_start = none
331 return
332 is "Space"
333 search = search ++ " "
334 is "Backspace"
335 search = search.to(-2)
336 else if key.length == 1
337 search = search ++ key
339 search = search.lower()
340 self.search = search
342 search_start := self.search_start or 1
343 for offset in (0).to(self.entries.length-1)
344 index := (search_start + offset) mod1 self.entries.length
345 line := self.entries[index] or skip
346 if search.length == 1
347 # Single letter searches should find exact match
348 if (self.get_entry(index) or skip).text == search
349 self.set_cursor(index)
350 stop
351 else if line.lower().has(search)
352 # Otherwise look for text match on the whole record
353 self.set_cursor(index)
354 stop
356 func move_cursor(self:&TableViewer, delta:Int)
357 self.set_cursor(self._cursor + delta)
359 func set_cursor(self:&TableViewer, pos:Int)
360 size := get_size()
361 self._cursor = Int.clamped(pos, 1, self.entries.length)
362 table_height := size.y - 2
363 self._top = Int.clamped(Int.clamped(self._top, self._cursor - table_height + 5, self._cursor + - 5), 1, self.entries.length - table_height)
365 func move_scroll(self:&TableViewer, delta:Int)
366 size := get_size()
367 self._top = Int.clamped(self._top + delta, 1, self.entries.length)
368 table_height := size.y - 2
369 self._cursor = Int.clamped(self._cursor, self._top + 5, self._top + table_height - 5)
371 func copy_to_clipboard(text:Text -> Bool)
372 success := no
373 C_code `
374 int fds[2];
375 pipe(fds);
376 pid_t child = fork();
377 if (child == 0) {
378 close(fds[1]);
379 dup2(fds[0], STDIN_FILENO);
380 #ifdef __APPLE__
381 execlp("pbcopy", "pbcopy", NULL);
382 #else
383 execlp("xclip", "xclip", "-selection", "clipboard", NULL);
384 #endif
385 errx(1, "Could not exec!");
387 close(fds[0]);
388 const char *str = @(text.as_c_string());
389 write(fds[1], str, strlen(str));
390 close(fds[1]);
391 int status;
392 waitpid(child, &status, 0);
393 @success = (WIFEXITED(status) && WEXITSTATUS(status) == 0);
395 return success
397 func main(codepoint|c:Int32?=none, text|t:Text?=none, theme|T:Theme?=none)
398 C_code `
399 static const char unicode_table[] = {
400 #embed "../UnicodeData.txt"
401 ,0,
404 table_lines := C_code:Text`Text$from_str(unicode_table)`.lines()
406 set_mode(TUI)
407 hide_cursor()
409 viewer := TableViewer(table_lines, theme=theme or Theme.guess())
411 # Set cursor position according to CLI flags
412 if c := codepoint
413 start_text := c.hex(digits=4, prefix=no) ++ ";"
414 for i,line in viewer.entries
415 if line.starts_with(start_text)
416 viewer.set_cursor(i)
417 stop
418 else if t := text
419 for i,line in viewer.entries
420 entry := UnicodeEntry.parse(line) or skip
421 if t.starts_with(entry.text or skip)
422 viewer.set_cursor(i)
423 stop
425 viewer.draw()
426 while not viewer.quit
427 prev := viewer
428 viewer.update()
429 if viewer != prev
430 viewer.draw()
432 disable()