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.1

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.1
File Size Uploaded Provenance
cmdgui-0.1.1.tar.gz 35.5 kB With provenance Details

Built distribution (wheel)

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

Total release size: 70.0 kB

Release files / cmdgui-0.1.1.tar.gz

Download URL cmdgui-0.1.1.tar.gz
Size 35.5 kB
Tags Source
SHA-256 checksum
How to use checksums
5a9a9e9fadd39f3939c17aba79b1467a1fc241214830a11f5c21bbd214614b82
BLAKE2b-256 checksum
How to use checksums
89319016d1835bfdc45c485e56c4c02ebd5ec4b44daa7c17dd5f6d948767a9bf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

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

Download URL cmdgui-0.1.1-py3-none-any.whl
Size 34.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d3c3a60c2329ef4d8dd24ae58b7f700dce49989e4d7c1367a6517cd4ac7e1ed8
BLAKE2b-256 checksum
How to use checksums
671a092d637facfaac1e5254693977712394baa90dad9fa31337f08bddd151ec
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

Security for cmdgui 0.1.1

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for cmdgui-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

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