xnano
An extremely nano terminal experience for Python, built on ratatui.
xnano is a terminal framework for Python, with an API that is quick to learn and
flexible enough to grow with your program. With xnano you can:
- Draw text, widgets, pixel art and images anywhere in your terminal window.
- React to keys, the mouse and the clock with hooks.
- Play notes and melodies, and listen to the microphone, with
xnano-audio. - Build without a terminal: a canvas from
xnano.new()draws into memory, so you can print it, test it, and show it on the screen when you are ready.
Requirements
- Python 3.10 to 3.14.
- Linux (x86_64 or aarch64, glibc or musl), macOS (Intel or Apple silicon) or Windows (x86_64). Each of these has pre-built wheels, so no Rust toolchain is needed.
Installation
Install xnano from PyPI:
pip install xnano
Images and sound are optional extras. When you use a feature whose extra is missing,
xnano tells you which one to install.
pip install "xnano[image]" # pictures: .png, .jpg, .gif, .webp
pip install "xnano[audio]" # sound: the speaker and the microphone
On Linux, playing sound on a device needs ALSA (libasound). Composing and rendering
sound work without a device everywhere.
Quick Start
Create it
Create a file main.py with:
import xnano
with xnano.open() as terminal:
terminal.write('Hello, world!', x='center', y='center')
terminal.run(stop_on='q')
Run it
python main.py
Hello, world! appears in the middle of your terminal window, and stays there
until you press q. You already have a program that:
- opens a live terminal with
xnano.open(), which takes over the whole window (give it aheightto draw in a region below your prompt instead), - writes text with
terminal.write(), at a position such as'center', - keeps running with
terminal.run()until one of itsstop_onkeys is pressed.
Example upgrade
Now make the text change color when you click it. Update main.py:
import xnano
# `mouse=True` turns on mouse events
with xnano.open(mouse=True) as terminal:
greeting = terminal.write('Hello, world!', x='center', y='center')
# a hook runs a function when something happens, and a hook on a placement only
# hears the events inside its own cells
@greeting.hook(on='mouse')
def change_color(context: xnano.Context):
# a click is a press and then a release: change the color once, on the press
if context.mouse.kind == 'press':
greeting.color.fg.random()
terminal.run(stop_on='q')
Run python main.py again and click Hello, world!: every click gives it a new
color.
Recap
xnano.open()is a live terminal.xnano.new()makes a canvas in memory, which you can print, or show in a terminal later. Both have the samescreen,clock,keyboard,mouseandspeaker.terminal.write()andcanvas.write()place text, widgets and images with a number of cells, a percentage or a word. Each is the same asscreen.write(), and each returns a placement that you can change at any time.hook(on=...)runs a function when a key, the mouse or the clock does something, and thecontextit receives says what happened.
For a step-by-step introduction, see Learn in the documentation.
Core Features & Examples
Every example below is a complete program: copy it into a file and run it. Press
q to leave the live ones.
Composing Content
The Canvas is a stateful builder for a rectangular region of a terminal
screen, from a one-line header to a multi-window layout. A canvas from
xnano.new() draws into memory, so it can be printed without a live terminal.
import xnano
# `layout='row'` gives everything written on the canvas an equal share of its width,
# and `x` and `y` place each piece inside its own share
with xnano.new(width='100%', height=7, layout='row') as canvas:
canvas.write(
'left panel', x='left', y='bottom', border='plain', foreground=(255, 0, 0)
)
canvas.write(
'center panel',
x='center',
y='center',
border='rounded',
foreground='black',
background='#f0f0f0',
)
canvas.write(
'right panel', x='right', y='top', border='thick', foreground='blue'
)
# render one frame of the screen as colored text, and print it
print(canvas.render(format='ansi'))
Positioning & Sizing Content
Positions and sizes all take the same forms: a number of cells, a percentage of
the space around the content, or a word such as 'center' or 'bottom'.
Percentages and words are kept, not computed once, so content stays where you
put it when the window or the content changes. Positions can also be expressions
that change on every frame (see the Clock example) or ranges that limit how
far content can move (see the Mouse example).
import xnano
with xnano.new(width='100%', height=7) as canvas:
# content written without a position goes on the next line, the way a terminal prints
canvas.write('first line')
canvas.write('second line')
# a position or a size is a number of cells, a percentage or a word
canvas.write('x=40', x=40, y=0)
canvas.write("x='75%'", x='75%', y=1)
canvas.write("x='right', y='bottom'", x='right', y='bottom')
panel = canvas.write('panel', x='center', y='center', width='50%', border='rounded')
# a placement takes new values at any time, and its words follow: it stays centered
panel.width = '60%'
canvas.write(
f'the panel is {panel.width} cells wide, from column {panel.x}', y='bottom'
)
print(canvas.render(format='ansi'))
Styling Content
All of ratatui's styling is available in
xnano, as keywords of screen.write() (foreground, background, bold,
italic, border, title, ...) and as attributes of what it returns
(placement.color.fg). This includes:
- Background & foreground colors:
names such as
'red','#rrggbb'strings,(red, green, blue)tuples, the 256 ANSI colors (xnano.Color.indexed(208)) and Tailwind's palette (xnano.Color.from_tailwind('sky', 500)). - Text modifiers: bold, dim, italic, underline, blink, reverse, hidden and crossed out.
- Borders & border styling:
'plain','rounded','double'and'thick'borders, with a title and a style of their own.
Terminal and Terminal Devices
xnano.open() returns a Terminal: a canvas on your real screen that runs by
itself. Every canvas, live or not, has the same devices: canvas.clock,
canvas.screen and the others below (and context.clock and so on inside a
hook).
Clock: the time of the canvas, its frame rate (fps) and tempo (bpm), and pausing and resuming everything that follows it.Screen: the cells the canvas draws, andwrite()to put content on them.Keyboard&Mouse: the key presses, pastes, clicks, drags and scrolls the canvas receives. They can also press keys and buttons for your program.Speaker: plays notes, melodies and sound files. (Requiresxnano-audio)Microphone: the level of the sound coming in from the system's microphone. (Requiresxnano-audio)
Terminal
import xnano
# give `xnano.open()` a `height` to draw in a region below your prompt, or leave it out
# to take over the whole window
with xnano.open(height=3, title='my app') as terminal:
terminal.write('Welcome to xnano!', border='rounded')
label = terminal.write('press space', x=22, y=1)
# a hook on the terminal itself hears every event on the screen
@terminal.hook(on='key', keys='space')
def pressed(context: xnano.Context):
label.text = f'space at {context.clock.elapsed_seconds:.1f}s'
# `run()` keeps the terminal open until one of the `stop_on` keys is pressed
terminal.run(stop_on='q')
Clock
import xnano
from xnano.expressions import ParentWidth
with xnano.open(height=3) as terminal:
label = terminal.write('')
ball = terminal.write('●', y=1)
# math with the clock makes an expression, which moves the ball on every frame;
# `ParentWidth` is the screen's width, read again when the window is resized
ball.x = (terminal.clock * 10) % ParentWidth
# tick hooks run on a timer, measured in seconds of the clock
@terminal.hook(on='tick', every=1.0)
def show_time(context: xnano.Context):
label.text = f'{context.clock.elapsed_seconds:.0f}s on the clock'
# `pause()` freezes everything that follows the clock, and `resume()` lets it run on
@terminal.hook(on='key', keys='space')
def toggle(context: xnano.Context):
if context.clock.is_paused:
context.clock.resume()
else:
context.clock.pause()
label.text = 'paused: press space to resume'
terminal.run(stop_on='q')
Screen
import xnano
# each service's response time, and its requests over the last 30 seconds
services = {
'api': ('41 ms', '▂▃▃▅▃▂▃▅▆▅▃▂▃▅▆▅▃▂▂▃▅▃▂▃▅▆▅▃▂▃'),
'web': ('870 ms', '▃▅▆▅▃▂▂▃▅▇██▇▇█▇▅▆▇██▇▆▇█▇▆▅▆▇'),
'worker': ('12 ms', '▁▁▂▁▁▃▁▁▁▂▅▂▁▁▁▂▁▁▃▂▁▁▁▂▁▁▁▂▃▁'),
}
# a canvas from `xnano.new()` needs no live terminal: `width='100%'` fills the window
with xnano.new(width='100%', height=7) as canvas:
screen = canvas.screen
screen.write('services', bold=True)
screen.write(f'{screen.width}×{screen.height} cells', x='right', y=0, dim=True)
cards = {}
for index, (name, (latency, graph)) in enumerate(services.items()):
cards[name] = screen.write(
['● online', f'p95 {latency}', graph],
x=f'{index * 34}%',
y=1,
width='32%',
border='rounded',
title=name,
foreground='green',
)
# `write()` returns a live placement: its text and colors can change at any time
cards['web'].text = cards['web'].text.replace('● online', '▲ slow responses')
cards['web'].color.fg = 'yellow'
# and they can be read back, like any other value
healthy = sum(card.text.startswith('●') for card in cards.values())
screen.write(f'{healthy} of {len(cards)} services healthy', y='bottom', dim=True)
print(screen.render(format='ansi'))
Keyboard
import xnano
# `edges='stop'` keeps content inside the terminal when it moves
with xnano.open(height=8, edges='stop') as terminal:
player = terminal.write('@', x='center', y='center')
terminal.write(
'arrows or wasd to move · ctrl+r to reset · q to quit', y='bottom', dim=True
)
# `on='key'` hooks run on every key press, and again while a key repeats
@terminal.hook(on='key')
def move(context: xnano.Context):
# `direction()` reads the arrow keys (or wasd) as one step along each axis
step_x, step_y = context.keyboard.direction()
player.move(step_x, step_y)
# `keys=` narrows a hook to one key, or to a binding with modifiers
@terminal.hook(on='key', keys='ctrl+r')
def reset(context: xnano.Context):
player.x, player.y = 'center', 'center'
terminal.run(stop_on='q')
Mouse
import xnano
# `mouse=True` turns on mouse events
with xnano.open(height=3, mouse=True) as terminal:
terminal.write('─' * 30, y=1)
label = terminal.write('drag the ●', y=2)
# a range keeps the thumb's `x` between two columns, however far it is dragged
thumb = terminal.write('●', x=(0, 29), y=1)
# a drag hook is a generator: each `yield` waits for the next movement of the pointer
@thumb.hook(on='drag')
def slide(context: xnano.Context):
while context.mouse.is_down:
thumb.x = context.parent.local(context.mouse.position).x
context = yield
label.text = f'dropped at {thumb.x}'
terminal.run(stop_on='q')
Speaker
Notes and melodies are plain values. A live terminal plays them through its
speaker, and outside one, xnano.play() plays them through your computer's
sound output.
import xnano
scale = ['C4', 'D4', 'E4', 'G4', 'A4']
chord = xnano.Note(('C4', 'E4', 'G4'), beats=2, instrument='electric_piano')
with xnano.open(height=2) as terminal:
terminal.write('press 1-5 for a note, space for a chord')
playing = terminal.write('', y=1, foreground='cyan')
@terminal.hook(on='key')
def play_note(context: xnano.Context):
key = context.keyboard.key
if key in ('1', '2', '3', '4', '5'):
pitch = scale[int(key) - 1]
playing.text = f'♪ {pitch}'
note = xnano.Note(pitch, beats=0.5)
elif key == 'space':
playing.text = '♪ C4 E4 G4'
note = chord
else:
return
# a computer without a sound output device (over SSH, for example) only shows it
if context.speaker.is_available:
context.speaker.play(note)
# a melody can also be written as text: a pitch, and its length in beats after a `:`
if terminal.speaker.is_available:
terminal.speaker.play('C4 E4 G4:2', instrument='piano')
terminal.run(stop_on='q')
Microphone
import xnano
with xnano.open(height=2) as terminal:
terminal.write('speak into your microphone')
# `level` is an expression from 0.0 to 1.0, so content can follow it on every frame
# (the default input device opens the first time a frame reads it)
level = terminal.microphone.level
terminal.write('░' * 40, x=0, y=1, dim=True)
terminal.write(
'█' * 40, x=0, y=1, width=level * 40 + 1, foreground='green', layer=1
)
terminal.run(stop_on='q')
Terminal Widgets
All of ratatui's built-in widgets are
in xnano.widgets, where ratatui's List is ItemList and its Canvas is
Figure. A widget is written, sized and bordered like any other content:
import math
import xnano
from xnano.styles import Style
from xnano.widgets import Bar, BarChart, Gauge, Sparkline, Table, TableCell, TableRow
# requests per second over the last 98 seconds
requests = [
round(6 + 3 * math.sin(second / 5) + 2 * math.sin(second / 2)) for second in range(98)
]
with xnano.open(height=13) as terminal:
terminal.write(
Gauge(0.6, label='60% of 512 GB', gauge_style=Style(foreground='green')),
width='100%',
border='rounded',
title='disk',
)
terminal.write(
Sparkline(requests, style=Style(foreground='cyan')),
width='100%',
height=4,
border='rounded',
title='requests',
)
load = terminal.write(
BarChart(
[
Bar(73, label='cpu'),
Bar(41, label='memory'),
Bar(18, label='network'),
Bar(56, label='disk io'),
],
bar_width=8,
bar_gap=3,
bar_style=Style(foreground='magenta'),
),
width='48%',
height=6,
border='rounded',
title='load',
)
# a placement's position can be read back, so the table lines up with the chart
terminal.write(
Table(
[
['web', 'up', '41 ms'],
['api', 'up', '12 ms'],
['db', TableCell('down', style=Style(foreground='red')), '—'],
],
header=TableRow(['service', 'status', 'p95'], style=Style(bold=True)),
),
x='right',
y=load.y,
width='48%',
height=6,
border='rounded',
title='services',
)
terminal.run(stop_on='q')
Images & Pixel Art
Pixel art is written as text in your code with xnano.pixel_art(). Pictures are
loaded from files with xnano.load_image(), from the xnano-image package
(built on ratatui-image).
Pixel Art
import xnano
# each character is one pixel: the palette names the colors, and `.` is see-through
heart = xnano.pixel_art(
"""
..RR...RR..
.RWWR.RRRR.
RRWRRRRRRRR
RRRRRRRRRRR
.RRRRRRRRR.
..RRRRRRR..
...RRRRR...
....RRR....
.....R.....
""",
'R=#e03040 W=#ffffff',
)
# two pixels fit in one cell, one above the other, so the heart takes 11 by 5 cells
print(xnano.region(heart).render(format='ansi'))
Images
Images need the image extra:
pip install "xnano[image]"
A picture can be loaded and printed without opening a live terminal:
import pathlib
import xnano
# this picture ships with xnano, so the example runs anywhere: use any path of your own
path = pathlib.Path(xnano.__file__).parent / 'assets' / 'dusk.png'
# `.png`, `.jpg`, `.webp` and animated `.gif` files load, sized in cells or a percentage
picture = xnano.load_image(path, width='100%')
# `xnano.region()` draws content into cells without a terminal, so it can be printed
print(xnano.region(picture).render(format='ansi'))
Learn More
- Documentation: the documentation site.
- Learn: a step-by-step introduction.
- API Reference: every public module, class and function.
- Contribution Guide: how to set up the development workspace, make changes and open a pull request.
xnano is released under the MIT License.
Metadata
Release files for xnano 2.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| xnano-2.2.0.tar.gz | 1.9 MB | Without provenance | Details for xnano-2.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| xnano-2.2.0-py3-none-any.whl | Python 3 | none | any | Details for xnano-2.2.0-py3-none-any.whl |
Total release size: 2.7 MB
Release files / xnano-2.2.0.tar.gz
| Download URL | xnano-2.2.0.tar.gz |
|---|---|
| Size | 1.9 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
890593d0227ebaa2da6dc20150a42ccf901b80353ba52dd065a3ff8fb14d5ac2
|
|
BLAKE2b-256 checksum How to use checksums |
dfb9490e3e52bcfe38aed0befc6bbe98fab83b32eb1cf779db3e615cef0640d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / xnano-2.2.0-py3-none-any.whl
| Download URL | xnano-2.2.0-py3-none-any.whl |
|---|---|
| Size | 832.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bb316767219f2cc784477e729be2e0ba22dcad77cc7599f1e9562f41c1332568
|
|
BLAKE2b-256 checksum How to use checksums |
294c1a7f39ef7f635ca2bf61c8efbd88b9eebd6f88155573f7a62b31bb08454d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for xnano 2.2.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.