Skip to main content

cmdgui

Simple terminal GUIs in Python. Draw your layout as text, fill in the widgets, and keep writing your program — the view runs on its own thread, so there's no event loop to hand control to and no draw calls to make.

┌─ name ───────────────────┐
│quinn                     │[         Greet          ] ON   fast
├─ fruit ──────────────────┼─ scores ──────────────────────────────────┐
│ apple                    │name      score  city                      │
│ banana                   │Ada       98     London                    │
│ cherry                   │Linus     87     Helsinki                  │
│                          ├─ log ─────────────────────────────────────┤
│                          │Hello, quinn! 👋                           │
│                          │picked cherry                              │
└──────────────────────────┴───────────────────────────────────────────┘
 ████████████████████████████████░░░░░░░░░░░░░░░░  67%[ ] done

No dependencies. macOS and Linux; Windows support is written but untested.

Install

pip install cmdgui

Quick start

from cmdgui import View, Button, Stdout
import time

class App(View):
    layout = """
        start  pause  .
        log    -      -
    """
    start = Button("Start", on_click=lambda: print("started"))
    pause = Button("Pause", on_click=lambda: print("paused"))
    log = Stdout()

view = App()

for i in range(100):
    print(f"tick {i}")   # shows up in the log box
    time.sleep(0.5)

Press q to quit (or Ctrl+C). Because the widgets are class attributes, your editor knows view.start is a Button — autocomplete and type checking work.

Three ways to build a view

They can be mixed freely:

from cmdgui import View, Label

# 1. A subclass: everything in one place, fully typed in your editor
class App(View):
    layout = "heading \n stdout"
    heading = Label("Hello", align="center")
view = App()

# 2. Widgets passed in: quick one-off views
view = View("heading \n stdout", heading=Label("Hello", align="center"))

# 3. Types in the layout, configured afterwards
view = View("label[heading] \n stdout")
view.heading.set(text="Hello", align="center")

A keyword argument overrides a subclass's widget of the same name. Each view made from a subclass gets its own copies of the widgets. For typed access to widgets declared in the layout string, use view.get("heading", Label).

Layouts

Each word is a grid cell. Write type[name] to create a widget:

Token Meaning
button[start] a button widget named start
heading the widget you passed in (or declared on the class) as heading
stdout otherwise: a widget whose name is its type
start the existing widget start again — it spans into this cell
- same as the cell to the left
| same as the cell above
. empty cell
{b} / {nb} on the first cell of a widget: force a border on / off

Every widget has to cover a rectangle. Get widgets with view.start or view["start"].

Sizes come from the widgets: a button wants to be 1 line tall, so its row is 1 line tall; everything else shares the remaining space. If the terminal is too small for the layout's minimum size, the view shows a message until it's resized.

Borders are drawn by the view, and neighbours share a single line with the right junctions (├ ┬ ┼). The border title is the widget's name, or its title. The focused widget's border is highlighted.

Widgets

Type Class What it does
text Text Wrapped text. text, align (left/center/right)
label Label One line of text
button Button text, on_click(fn). Click, or Enter/Space when focused
text_input TextInput value, placeholder, on_submit(fn), on_change(fn)
progress_bar ProgressBar value from 0 to 1
checkbox Checkbox text, checked, on_change(fn)
toggle Toggle An on/off switch, same API as Checkbox
menu Menu items, selected, on_select(fn(index, item))
table Table columns, rows. Scroll with the mouse wheel
stdout Stdout Everything printed, stderr in red. Scroll with the mouse wheel. clear()

Every widget also takes border, title, preferred_width and preferred_height. The first positional argument is the main content: Label("text"), Menu(items), Table(columns), ProgressBar(0.5). Callbacks can be passed in (on_click=) or set later (button.on_click(fn)).

Setting an attribute redraws the widget: view.bar.value = 0.5 just works, and widget.set(text=..., align=...) changes several at once. If you change a list in place (view.menu.items.append(...)), call widget.refresh().

Popups

A popup floats over the layout. It has its own layout and widgets, just like a view, and sizes itself to fit them:

from cmdgui import View, Popup, Label, Button, Stdout

class Confirm(Popup):
    layout = """
        message  -
        yes      no
    """
    title = "Clear the log?"
    message = Label("This can't be undone.")
    yes = Button("Clear")
    no = Button("Cancel")

    def init(self):                    # wire up the popup's own widgets
        self.no.on_click(self.close)

class App(View):
    layout = "clear \n log"
    clear = Button("Clear log", on_click=lambda: view.show(view.confirm))
    log = Stdout()
    confirm = Confirm()                # popups can live on the view too

view = App()
view.confirm.yes.on_click(lambda: (view.log.clear(), view.confirm.close()))
  • view.show(popup) opens it in the middle, or use below=widget, above=widget or at=(x, y). It flips to the other side if there's no room.
  • popup.close(), popup.is_open, popup.on_close(fn).
  • view.alert("Saved!", title="Done") shows a message with an OK button.
  • A popup with one widget doesn't need a layout: Popup(Menu(items)), or a subclass with a single widget.

Settings, as class attributes or constructor arguments:

Setting Default
modal True blocks clicks and focus for everything underneath
close_on_escape True
close_on_outside_click False for a modal popup the click just closes it; otherwise it goes through too
keep_typing False the focused text box keeps getting typed text, while arrows and Enter go to the popup (autocomplete, command menus)
border, title True, None
width, height None outer size; None fits the content

Inside a popup, a widget's border title is only shown if you set its title. examples/simple_console.py has a / command menu and examples/popups.py has a dialog and a dropdown.

Keys and focus

  • Tab / Shift+Tab or a click moves focus between widgets that take input.
  • Key presses go to the focused widget; typed characters go to a focused text box first.
  • view.on_key("ctrl+s", save) binds a key. Key names: letters, enter, escape, tab, backspace, delete, up/down/left/right, home/end, page_up/page_down, ctrl+a, alt+x, ctrl+up, ...
  • View(layout, quit_key="q") sets the quit key; quit_key=None turns it off.

Ending the program

with View("...") as view:
    ...
    view.wait()   # blocks until q, view.quit() or Ctrl+C

Without wait(), the quit key ends your program as if it had finished. If anything raises — in your code or inside a callback — the terminal is put back to normal first, so the traceback is visible.

Your own widgets

from cmdgui import Widget, View, field

class Clock(Widget):
    time: str = field(default="", kw_only=False)  # fields become constructor arguments
    show_seconds: bool = True
    history: list = field(default_factory=list)   # a fresh list for each clock

    preferred_height = 1          # 5, "5+" (at least), "5-10" (between), or None
    border = True

    def init(self):               # other setup (not constructor arguments)
        self.ticks = 0

    def draw(self, c):            # c is a Canvas exactly the widget's size
        c.text(0, 0, self.time)

    def on_input(self, input):    # mouse, stdout, and keys (while focused)
        pass

view = View("clock")              # registered automatically as "clock"
view = View("now", now=Clock("12:00"))

Annotated attributes are fields: keyword arguments by default, positional with field(kw_only=False). Editors autocomplete and type-check them like a dataclass.

Override content_size() to return the (width, height) your content wants, so popups can size themselves around it (None for either means "don't care").

Canvas has put, text, fill and border; self.mouse_pos() and self.mouse_over() give the mouse relative to the widget; self.theme("key") gets a style from the theme.

Themes

from cmdgui import View, style

view = View("...", theme={
    "border_focus": style(fg="magenta", bold=True),
    "selected": style(fg="black", bg="yellow"),
})

See DEFAULT_THEME in cmdgui.widgets for every key.

Metadata

Release files for cmdgui 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cmdgui 0.1.0
File Size Uploaded Provenance
cmdgui-0.1.0.tar.gz 34.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for cmdgui 0.1.0
File Interpreter ABI Platform
cmdgui-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 68.5 kB

Release files / cmdgui-0.1.0.tar.gz

Download URL cmdgui-0.1.0.tar.gz
Size 34.9 kB
Tags Source
SHA-256 checksum
How to use checksums
6ed92467e1d18a630517957590215562a84cf531a47ea5a6e85f8622551dbf01
BLAKE2b-256 checksum
How to use checksums
54facbc1073031c4755ac63af10b3ee1fc22d206d08350294fa2e279a21b4690
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.4

Release files / cmdgui-0.1.0-py3-none-any.whl

Download URL cmdgui-0.1.0-py3-none-any.whl
Size 33.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bf939e01e6994e7b1725b2dbc8117ee1c6f665ca1b14251e6efaedd103708d0f
BLAKE2b-256 checksum
How to use checksums
255f3ea1373a9516edad9a5237477d0a8ab43583090a3cc0323237c2c59dc12d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.4

Security for cmdgui 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page