Download
432 lines 15.8 KiBTomoModified 2026-05-08 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
# A unicode table viewer TUI
use btui
use <sys/wait.h>
use ./names.tm
use ./themes.tm

BINARY: "unicode"
HELP: "
    `unicode` is a Tomo program to view information about the Unicode 3.1 standard
    codepoints. The table viewer is an interactive text user interface with the
    following controls:

        q              - Quit the program
        j/k or up/down - Move up or down one entry
        Ctrl+d/Ctrl+u  - Move up or down one page
        g/G            - Move to top/bottom
        Ctrl+c or y    - Copy the text of an entry to the clipboard
        u              - Copy the codepoint of an entry (U+XXXX) to the clipboard
        d              - Copy the decimal codepoint of an entry to the clipboard
        p              - Exit and print the unicode entry to stdout
        Ctrl+f or /    - Search for text (enter to confirm)
        n/N            - Jump to next/previous search result
        i              - Toggle info panel
"
MANPAGE_DESCRIPTION: "
    `unicode` is a Tomo program to view information about the Unicode 3.1 standard
    codepoints. The table viewer is an interactive text user interface.
"
LICENSE: ./LICENSE.md

struct UnicodeBlock(first,last:Int32, description:Text)
    UNICODE_BLOCKS : [UnicodeBlock] = UnicodeBlock.load_all()

    func load_all(-> [UnicodeBlock])
        C_code `
            static const char unicode_blocks[] = {
                #embed "../UnicodeBlocks.txt"
                ,0,
            };
        `
        blocks : &[UnicodeBlock] = &[]
        for line in C_code:Text`Text$from_str(unicode_blocks)`.lines()
            skip if line.length == 0 or line.starts_with("#")
            sections := line.split(";")
            range := sections[1]!.split("..")
            low := Int32.parse(range[1]!, 16)!
            high := Int32.parse(range[2]!, 16)!
            block := UnicodeBlock(low, high, sections[2]!.trim())
            blocks.insert(block)
        assert blocks.length > 0
        return blocks

    func find(codepoint:Int32 -> UnicodeBlock?)
        target := UnicodeBlock(codepoint, codepoint, "")
        i := UnicodeBlock.UNICODE_BLOCKS.binary_search(func(b:&UnicodeBlock) b[] >= target) or return none
        return UnicodeBlock.UNICODE_BLOCKS[i]


struct UnicodeEntry(
    codepoint:Int32,
    text:Text?=none,
    name:Text="",
    category:Text="",
    combining_class:Text="",
    bidi_class:Text="",
    decomposition_mapping:Text="",
    decimal_digit:Int?=none,
    digit:Int?=none,
    numeric:Int?=none,
    mirrored:Bool=no,
    unicode_1_name:Text?=none,
    iso_comment:Text?=none,
    simple_uppercase:Int32?=none,
    simple_lowercase:Int32?=none,
    simple_titlecase:Int32?=none,
)
    func parse(text:Text -> UnicodeEntry?)
        # For format details, see: https://www.unicode.org/L2/L1999/UnicodeData.html
        items := text.split(";")
        entry := UnicodeEntry(Int32.parse((items[1] or return none), 16) or return none)
        entry.text = Text.from_utf32([entry.codepoint])
        entry.name = items[2] or return none
        entry.category = items[3] or return none
        entry.combining_class = items[4] or return none
        entry.bidi_class = items[5] or return none
        entry.decomposition_mapping = items[6] or return none
        junk : Text
        entry.decimal_digit = Int.parse(items[7] or return none, remainder=&junk)
        entry.digit = Int.parse(items[8] or return none, remainder=&junk)
        entry.numeric = Int.parse(items[9] or return none, remainder=&junk)
        entry.mirrored = items[10] == "Y"
        entry.unicode_1_name = (if items[11]!.length > 0 then items[11]!)
        entry.iso_comment = items[12]
        entry.simple_uppercase = Int32.parse((items[13] or return none), 16, &junk)
        entry.simple_lowercase = Int32.parse((items[14] or return none), 16, &junk)
        entry.simple_titlecase = Int32.parse((items[15] or return none), 16, &junk)
        return entry

    func info(self:UnicodeEntry -> {Text:Text})
        block := UnicodeBlock.find(self.codepoint)

        return {
            "Symbol": (if self.codepoint > 32 then self.text or "" else ""),
            "Block": (if b := block then b.description else ""),
            "UTF32": "$(self.codepoint.hex()) ($(self.codepoint))",
            "UTF16": (if text := self.text then " ".join([u.hex() for u in text.utf16()]) ++ " (" ++ " ".join([Text(u) for u in text.utf16()]) ++ ")" else "")
            "UTF8": (if text := self.text then " ".join([b.hex() for b in text.utf8()]) else "")
            "Name": self.name,
            "Unicode 1 name": self.unicode_1_name or "",
            "Category": named(self.category, CATEGORY_NAMES),
            "Combining class": named(self.combining_class, COMBINING_NAMES),
            "Bidi class": named(self.bidi_class, BIDI_NAMES),
            "Decomposition": named(self.decomposition_mapping, DECOMPOSITION_NAMES),
            "Digit": (if d := self.digit then Text(d) else ""),
            "Mirrored": Text(self.mirrored),
            "ISO comment": self.iso_comment or "",
            "Uppercase": (if u := self.simple_uppercase then Text.from_utf32([u])! else ""),
            "Lowercase": (if l := self.simple_lowercase then Text.from_utf32([l])! else ""),
            "Titlecase": (if t := self.simple_titlecase then Text.from_utf32([t])! else ""),
        }

    func draw(self:UnicodeEntry, y:Int, theme:Theme=Dark, highlighted=no)
        columns := [
            "$(
                if theme == Theme.None and highlighted then ">" else " "
            )U+$(self.codepoint.hex(digits=5, prefix=no))",

            (
                if text := self.text
                    if self.codepoint > 32
                        text
                    else
                        ""
                else
                    ""
            )

            (
                do
                    name := if self.name then self.name else "No name"
                    if desc := self.unicode_1_name
                        name ++= " "++desc
                    name.title()
            ),
        ]

        styles := [
            func() theme.row_codepoint(highlighted)
            func() theme.row_character(highlighted)
            func() theme.row_description(highlighted)
        ]

        widths := [10, 6, 32]

        x := 0
        for i,column in columns
            styles[i]!()
            write(" $column ", ScreenVec2(x, y))
            clear(Right)
            x += widths[i]!

struct TableViewer(
    entries:[Text],
    _top:Int=1,
    _cursor:Int=1,
    quit:Bool=no,
    show_info:Bool=yes,
    search_start:Int?=none,
    search:Text?=none,
    message:Text?=none,
    theme:Theme=Theme.Dark,
)
    func draw(self:TableViewer)
        size := get_size()
        self.theme.header()
        write(" Codepoint Symbol Description ", ScreenVec2(0,0))
        clear(Right)

        for y in (1).to(size.y - 1)
            row := self._top + y - 1
            entry := self.get_entry(row) or skip
            entry.draw(y, theme=self.theme, highlighted=(row == self._cursor))

        if self.show_info
            if entry := self.get_entry()
                info := entry.info()
                height := info.length + 2
                label_width := (_max_: k.width() for k in info.keys)!
                value_width := (_max_: v.width() for v in info.values)! _max_ 50
                width := label_width + 3 + value_width
                top_left := ScreenVec2(size.x - width - 1, 1)
                self.theme.box()
                fill_box(top_left, ScreenVec2(width, height))
                for i,label in info.keys
                    write(label, pos=top_left + ScreenVec2(label_width + 1, i), Right)
                self.theme.box_details()
                for i,value in info.values
                    write(value, pos=top_left + ScreenVec2(label_width + 2, i), Left)

        if search := self.search
            self.theme.search_label(self.search_start != none)
            write(" Search: ", ScreenVec2(0, size.y-1))
            self.theme.search_text(self.search_start != none)
            write(" "++search)
            clear(Right)

        if message := self.message
            self.theme.message_theme()
            write(" $message ", ScreenVec2(size.x-1, size.y-1), Right)
            clear(Right)

        # Scroll bar
        scroll_height := size.y-2
        scroll_top := 1 + (self._top * scroll_height)/self.entries.length
        scroll_bottom := 1 + ((self._top + scroll_height - 1) * scroll_height)/self.entries.length
        self.theme.scroll_bg()
        for y in (1).to(scroll_top-1, step=1)
            write(" ", ScreenVec2(size.x-1, y))
        self.theme.scroll_bar()
        for y in (scroll_top).to(scroll_bottom, step=1)
            write(" ", ScreenVec2(size.x-1, y))
        self.theme.scroll_bg()
        for y in (scroll_bottom+1).to(size.y-1, step=1)
            write(" ", ScreenVec2(size.x-1, y))

        flush()

    func update(self:&TableViewer)
        if self.search_start
            self.update_search()
            return

        size := get_size()
        mouse_pos := ScreenVec2(0, 0)
        key := get_key(&mouse_pos)
        when key
        is "j", "Down"
            self.move_cursor(1)
        is "Mouse wheel down"
            self.move_scroll(1)
        is "k", "Up"
            self.move_cursor(-1)
        is "Mouse wheel up"
            self.move_scroll(-1)
        is "Left press", "Left drag"
            if 1 <= mouse_pos.y and mouse_pos.y <= size.y-2
                if mouse_pos.x >= size.x-1
                    self.set_cursor((self.entries.length * (mouse_pos.y - 1)) / (size.y - 2))
                    # Prevent spamming the console too much
                    sleep(0.01)
                else if key == "Left press"
                    self.set_cursor(self._top + mouse_pos.y - 1)
        is "g"
            self.move_cursor(-self.entries.length)
        is "G"
            self.move_cursor(self.entries.length)
        is "q"
            self.quit = yes
        is "Escape"
            if self.search != none
                self.search = none
            else
                self.quit = yes
        is "Ctrl-d"
            self.move_scroll(size.y/2)
        is "Ctrl-u"
            self.move_scroll(-size.y/2)
        is "Ctrl-c", "y"
            if entry := self.get_entry()
                if text := entry.text
                    if copy_to_clipboard(text)
                        self.message = "Copied text!"
                    else
                        self.message = "Failed to copy to clipboard!"
        is "p"
            if entry := self.get_entry()
                if text := entry.text
                    disable()
                    print(text)
                    exit()
            
        is "u"
            if entry := self.get_entry()
                if copy_to_clipboard("U+$(entry.codepoint.hex())")
                    self.message = "Copied U+$(entry.codepoint.hex())!"
                else
                    self.message = "Failed to copy to clipboard!"
        is "d"
            if entry := self.get_entry()
                if copy_to_clipboard("$(entry.codepoint)")
                    self.message = "Copied $(entry.codepoint)!"
                else
                    self.message = "Failed to copy to clipboard!"
        is "i"
            self.show_info = not self.show_info
        is "/", "Ctrl-f"
            self.search = ""
            self.search_start = self._cursor
            self.message = none
        is "n"
            if search := self.search
                for offset in (0).to(self.entries.length-1)
                    index := (self._cursor + 1 + offset) mod1 self.entries.length
                    line := self.entries[index]!
                    if line.lower().has(search)
                        self.set_cursor(index)
                        stop
        is "N"
            if search := self.search
                for offset in (self.entries.length-1).to(0)
                    index := (self._cursor - 1 + offset) mod1 self.entries.length
                    line := self.entries[index]!
                    if line.lower().has(search)
                        self.set_cursor(index)
                        stop

    func get_entry(self:TableViewer, row:Int?=none -> UnicodeEntry?)
        return UnicodeEntry.parse(self.entries[row or self._cursor] or return none)

    func update_search(self:&TableViewer)
        key := get_key()
        search := self.search or ""
        when key
        is "Escape", "Ctrl-c"
            self.search = none
            self.search_start = none
            return
        is "Enter"
            # keep self.search so we can use 'n' to find it later
            self.search_start = none
            return
        is "Space"
            search = search ++ " "
        is "Backspace"
            search = search.to(-2)
        else if key.length == 1
            search = search ++ key

        search = search.lower()
        self.search = search

        search_start := self.search_start or 1
        for offset in (0).to(self.entries.length-1)
            index := (search_start + offset) mod1 self.entries.length
            line := self.entries[index] or skip
            if search.length == 1
                # Single letter searches should find exact match
                if (self.get_entry(index) or skip).text == search
                    self.set_cursor(index)
                    stop
            else if line.lower().has(search)
                # Otherwise look for text match on the whole record
                self.set_cursor(index)
                stop

    func move_cursor(self:&TableViewer, delta:Int)
        self.set_cursor(self._cursor + delta)

    func set_cursor(self:&TableViewer, pos:Int)
        size := get_size()
        self._cursor = Int.clamped(pos, 1, self.entries.length)
        table_height := size.y - 2
        self._top = Int.clamped(Int.clamped(self._top, self._cursor - table_height + 5, self._cursor + - 5), 1, self.entries.length - table_height)

    func move_scroll(self:&TableViewer, delta:Int)
        size := get_size()
        self._top = Int.clamped(self._top + delta, 1, self.entries.length)
        table_height := size.y - 2
        self._cursor = Int.clamped(self._cursor, self._top + 5, self._top + table_height - 5)

func copy_to_clipboard(text:Text -> Bool)
    success := no
    C_code `
        int fds[2];
        pipe(fds);
        pid_t child = fork();
        if (child == 0) {
            close(fds[1]);
            dup2(fds[0], STDIN_FILENO);
        #ifdef __APPLE__
            execlp("pbcopy", "pbcopy", NULL);
        #else
            execlp("xclip", "xclip", "-selection", "clipboard", NULL);
        #endif
            errx(1, "Could not exec!");
        }
        close(fds[0]);
        const char *str = @(text.as_c_string());
        write(fds[1], str, strlen(str));
        close(fds[1]);
        int status;
        waitpid(child, &status, 0);
        @success = (WIFEXITED(status) && WEXITSTATUS(status) == 0);
    `
    return success

func main(codepoint|c:Int32?=none, text|t:Text?=none, theme|T:Theme?=none)
    C_code `
        static const char unicode_table[] = {
            #embed "../UnicodeData.txt"
            ,0,
        };
    `
    table_lines := C_code:Text`Text$from_str(unicode_table)`.lines()

    set_mode(TUI)
    hide_cursor()

    viewer := TableViewer(table_lines, theme=theme or Theme.guess())

    # Set cursor position according to CLI flags
    if c := codepoint
        start_text := c.hex(digits=4, prefix=no) ++ ";"
        for i,line in viewer.entries
            if line.starts_with(start_text)
                viewer.set_cursor(i)
                stop
    else if t := text
        for i,line in viewer.entries
            entry := UnicodeEntry.parse(line) or skip
            if t.starts_with(entry.text or skip)
                viewer.set_cursor(i)
                stop

    viewer.draw()
    while not viewer.quit
        prev := viewer
        viewer.update()
        if viewer != prev
            viewer.draw()

    disable()