Skip to main content

A pygame UI framework. Easy to get right. Hard to get wrong.

Project description

IPUI - Idiot Proof UI - Because we've all spent 3 hours debugging a button!

Version: 0.1.0 Rev 057

A lightweight, opinionated Python/Pygame UI framework that makes building complex tabbed interfaces ridiculously simple.

Easy to get right, hard to get wrong. Define your entire app structure with one TAB_LAYOUT dictionary — no routing, no manual widget management, no layout math. IPUI handles scaffolding, discovery, reactivity, and the full Pygame lifecycle for you.

Python 3.10+ Pygame-ce License

Actively developed • First public release — May 2026

IPUI is developed and tested against pygame-ce. It is imported in code as pygame.

Full installation in section 2 but if you are just looking for the pip command...

python -m pip install ipui

Table of Contents


Additional Documentation


The IPUI Advantage

  • 🗂️ First-Class Tab System: Define your app's tabs, panes, and flex ratios from a single simple dictionary. IPUI scaffolds the structure and keeps each tab cleanly modular:
  TAB_LAYOUT = {
      "Dashboard": ["main_view"],                             # 1 full-screen pane
      "Settings" : ["sidebar", "options"],                    # 2 equal panes
      "Analytics": [("nav", .2), ("graph", .6), ("log", .2)]  # Custom flex sizing
  }
  • 📐 Declarative Layout: Simple, flexible syntax that handles the math so you can focus on the logic.
  • 🔗 Construction IS Attachment: No floating widgets or add() calls. If you build it inside a container, it's attached automatically.
  • 🧩 Built to Extend: Custom widgets get layout, events, and styling automatically. Standard widgets take 5–10 LOC; even tools like a network diagram widget come in under 150 LOC.
  • 📜 One-Touch Scrolling: Make any Card scrollable with a single parameter—no complex viewport setup required. Scrollbars are styled automatically.
  • 📱 Resolution Independent: UI scales automatically to physical screen height, so it stays usable on an old laptop or a 4K monitor. Changing aspect ratio can still cause issues
  • 🔄 Multiple Update Styles: Use DAG-based reactivity, pipeline-driven synchronization, or direct widget access—whichever fits the job best.
  • ⛓️ Data Pipeline: Bind widgets to a Pipeline Key and let IPUI propagate updates automatically. Derives stay in sync with zero manual update code.
  • 🎮 Pygame Lifecycle Hooks: ip_think, ip_draw, and ip_draw_hud give you full access to the game loop without fighting the framework.
  • 💡 Multi-Tier Tooltips: Choose between standard hover tips or "Super Tooltips"—pinnable, scrollable windows capable of displaying deep technical data.
  • 🗃️ Automatic Widget Registry: When DAG or pipeline isn't the right fit, named widgets stay easy to reach across tabs and panes; no reference plumbing required.
  • 🐞 Pro Debug Mode: Includes a live Widget Tree and layout overlays to make positioning issues easy to diagnose.
  • 💻 Beautiful Code Boxes: Display source code by passing a string or a file path; IPUI handles the formatting.
  • 🗺️ Tab Map: A bird's-eye view of your entire application for quick review and navigation.
  • 📊 Grid: The baddest grid in the pygame ecosystem — and not just because it looks good. Feed it lists, dicts, SQLite tables, or SQL queries; it handles pagination, sorting across pages, and query-wrapped filtering/sorting for database-backed views. It adapts to your workflow.
  • 📚 Self-Documenting: Documentation stays in sync with the framework by reading the source code directly.
  • 📈 Live Matplotlib Charts: Embed real-time updating research visuals directly in your pygame UI—useful for training curves, experiment monitoring, and diagnostics.

Resolution Independence

Can you tell which of these is running at 700×450 and which is at 3840×2160?

Showcase Screenshot

Showcase Screenshot

Neither should embarrass you on a laptop or a 4K monitor.

IPUI handles resolution automatically through three layered mechanisms:

1. Declarative layout is inherently unitless. Flex ratios, pane weights, and container relationships are all proportional. There are no hardcoded pixel coordinates to break when the window grows.

2. Text widgets are semantic, not sized. You don't ask for "14pt bold." You ask for a Title, Heading, Body, or Detail. IPUI maps those roles to sizes tuned for the current resolution — automatically.

3. Spacing tokens scale with the display. Padding, gaps, borders, and scrollbars are all derived from a single multiplier that snaps to a resolution bucket at startup:

Screen Height Multiplier Vibe
< 600px Tiny / embedded
600–1199px Laptop / 1080p
1200–1999px 1440p / ultrawide
2000px+ 4K and up

Want autopilot? Do nothing — it just works. Want fine-grained control? Override Style.TOKEN_MULTIPLIER and call Style.recalculate() to tune any token to your exact spec.


Installation

Create a clean project folder. Any name works — ipui-test is just an example.

mkdir ipui-test
cd ipui-test

Create and activate a virtual environment:

python -m venv testenv
rem This may take a moment. If it succeeds, Python returns to the prompt without printing a success message.

testenv\Scripts\activate.bat
rem After activation, your prompt should change to something like:
rem (testenv) C:\ipui-test

Install ipui:

python -m pip install ipui

Run the Showcase

Want to see what IPUI can do before you build a thing? Run docs() and you'll get a fully interactive widget gallery — every widget, every layout pattern, every trick, all live and clickable. It's the fastest way to go from "looks interesting" to "now I know what to steal."

from ipui import *
docs()

Showcase Screenshot


Important Note: Why IPUI Does Things Differently

IPUI intentionally makes choices that look unconventional if you're coming from tkinter, Qt, web UI frameworks, or typical Python library design.

Those choices are not accidents. They come from one core idea:

Anything the framework can solve once should not be re-solved by every user, in every widget, forever.

This affects many decisions.

We know Python's conventions and PEP 8; where we differ it is intentional.

For the full reasoning behind these design choices, see:

Appendix A: Why IPUI Does Things Differently


Quick Start

IPUI is built to grow across files, but the fastest way to start is with one file.

Get it running first. Then let IPUI help you split things out as your app grows.


Step 1: First Taste — Run in 30 Seconds

With just a few simple lines you will have

  • 3 Tabs
  • 4 Different labels
  • Button with modal message

QuickStart Screenshot

notepad SmokeTest.py

copy the below code and save.

from ipui import *

class SmokeTest(_BaseForm):                 # ← Doesn't need to match file but can
    TAB_LAYOUT = {
        "Hello World"   :["welcome"     ],   # ← The "Hello World" tab runs the builder 'welcome'
        "Widgets"       :["demo","demo2"],   # ← Will trigger template picker
        "Bouncing Ball" :["arena", None ],   # ← Will trigger template picker
    }
    
    def welcome(self, parent):               # ← Builder from "Hello World" in TAB_LAYOUT
        Banner  (parent, "IPUI"              , text_align=CENTER, glow=True)
        Body    (parent, "Easy to get right!", text_align=CENTER)
        Heading (parent, "Hard to get wrong.", text_align=CENTER)
        Title   (parent, "Because we've all spent 3 hours debugging a button", text_align=CENTER, glow=True)
        Button  (parent, "Click Me :)"       , on_click=lambda: self.form.show_modal("Hello"))
        
if __name__ == "__main__": show(SmokeTest)
python SmokeTest.py # or whatever you named your file.

Three tabs appear immediately:

  • Hello World — fully working with banner, text, and button
  • Widgets — show IPUI's helper card with template options
  • Bouncing Ball — show IPUI's helper card with template options

Something not sitting where you expect? Press F12 while it’s running and pop open the X-Ray debug tools.


Step 2: Open Widgets — Let IPUI Forge the File

Change to the 'Widgets' tab.

The welcome method defined the content for the Hello World tab. Widgets and Bouncing Ball do not have matching content yet.

Problem? Not even a little.

Instead of throwing an error or even showing an empty tab, IPUI steps in with a helper card:

Houston helper card

Pick Full Showcase on the Widgets tab. IPUI will create Widgets.py and hot-swap in a complete, interactive widget playground with real working controls (buttons, textboxes, cards, grids, etc.).

It's not a dead stub — it's a cookbook of live code you can immediately click, rearrange, and copy-paste from.


Step 3: Customize and Scale

IPUI generates Widgets.py a placeholder method named after the first pane you declared in TAB_LAYOUT.

Replace the placeholder content

In Widgets.py delete placeholder lines (11-39):

def demo(self, parent):
    Title(parent, "My Widget Tree", glow=True)

Save the file and re-run to see your changes.

This is the normal workflow:

  1. Add (or modify) entries in TAB_LAYOUT
  2. Let IPUI discover or generate the file(s)
  3. Edit the builder methods on your _BaseTab class
  4. Save and keep going

You can define pane methods directly inside _BaseForm (as in the smoke test) or in separate files — both work seamlessly.


Core Concepts

The Blueprint: TAB_LAYOUT

The TAB_LAYOUT dictionary is the core blueprint for your IPUI application. It defines your tabs and how their space is divided.

TAB_LAYOUT = {
    #Name of Tabs    Panes dividing up each tab. 
    "Hello World"   :["welcome"     ],  # Tab 'Hello World'   with one pane 'welcome'
    "Widgets"       :["demo","demo2"],  # Tab 'Widgets'       with two panes.
    "Bouncing Ball" :["arena", None ],  # Tab 'Bouncing Ball' with one pane 'arena' and a blank Pygame area
}

(Note: A pane value of None creates a raw, blank region for direct Pygame drawing!)

Panes Have Exactly Two Jobs

Divide the tab into visual regions (using optional flex numbers for sizing). Act as method names IPUI automatically looks for a method matching the pane's name to populate that specific region.

Where IPUI Looks for Pane Methods IPUI is highly flexible and will look for these methods in two places:

  1. The Main Form File (The Quick & Dirty Prototype) What you just did in SmokeTest.py

  2. Dedicated Tab Files When you are building a real application, you want modularity. IPUI uses a powerful, zero-friction file-linking system to manage this automatically.

If your tab is named "Hey There", IPUI will scan your project tree for a file named Hey_There.py (or HeyThere.py). Inside that file, it just looks for a class inheriting from _BaseTab. The actual class name does not matter.

This creates a clean, predictable hierarchy: Main Form ➔ Tab File (Hey_There.py) ➔ Pane Methods (def welcome(self):)

Why we link by File Name instead of Class Imports:

This unconventional approach solves three major UI development headaches:

  • Visual Project Structure: You can find tab logic by looking at your file explorer tree. There is no need to open files just to hunt down class names.
  • NO IMPORTS NEEDED — not needing them is nice. Not needing to maintain them as you revise content is even nicer.
  • NO CIRCULAR IMPORTS No extra imports. No extra risk of throwing this little party :)

The less we need to get right, the better!

# Widgets.py
from ipui import *

# The class name can be anything, as long as it inherits from _BaseTab
class TotallyWhateverNameYouWant(_BaseTab):

    # This matches the 'demo' pane in TAB_LAYOUT
    def demo(self, parent):
        Title(parent, "Hello from Widgets.py")

The Golden Rule: _BaseTab Wins

What happens if IPUI finds a demo() pane builder in both your main _BaseForm and an external Widgets.py file?

The external _BaseTab file always wins. This is deliberate. The main form is great for a fast start, but once a tab earns its own file, that file becomes the boss. If you extract a method into a new file and leave the old one behind, IPUI gracefully switches over to the new dedicated file.

Why the __name__ Guard Is Necessary

Your main file should always end with:

if __name__ == "__main__": show(SmokeTest)

Don't skip this! In a one-file setup, this standard Python guard prevents accidental re-entry during import.


The Widget Tree

Construction IS attachment.

AnyWidget(what_widget_do_i_attach_to, any_other_options, ...)

When you create a widget, the first argument is always the widget it attaches to.

Every pane method receives a parent parameter — the root widget of that pane.

Each pane has 1 tree and parent is the trunk.

def demo(self, parent): # ← parent is this pane's root widget card = CardCol(parent) # card attaches to the pane Title(card, "My Tree") # Title attaches to card Heading(card, "Same parent") # also attaches to card

No add(). No pack(). No grid(). Construction IS attachment — an entire class of "widget exists but isn't visible" bugs is gone.

Need to go deeper? Same rule:

        def demo(self, parent):                 # ← parent is root widget.     
            card  = Card(parent)                # This Card's parent is parent.
            Title(card, "My Tree")              # This Heading's parent is card.
            Heading(card, "Same parent")
        
            inner = Card(card)                  # A card nested inside the first card
            Body(inner, "I'm one level deeper") # A branch of inner
            Body(inner, "So am I")
        
            plate= Plate(inner)
            row1 = Row(plate)                   # back in the outer card, now horizontal
            Body(row1, "We Are")
            Body(row1, "Stuck")
            Body(row1, "Together")
        
            row2 = Row(plate,justify_spread=True)                 
            Body(row2, "We Have")
            Body(row2, "Plenty")
            Body(row2, "of Space")

Everything stacks vertically by default. Need widgets side by side? Row is a transparent horizontal container — pure structure, no visual chrome:

    def demo(self, parent):            # ← parent is the pane root
        card  = Card(parent)           # card attaches to the pane
        Title(card, "My Tree")         # Title attaches to card
        Heading(card, "Same parent")
    
        inner = Card(card)
        Body(inner, "I'm one level deeper")
        Body(inner, "So am I")
    
        row = Row(card)                 # back in the outer card, now horizontal
        Body(row, "Left")
        Body(row, "Middle")
        Body(row, "Right")

The pattern never changes:

  • First argument is the parent,
  • Attachment is Immediate.
  • Build the tree by building widgets.

Where does your logic live? — ip_* hooks

IPUI sets up the pygame engine and ensures all superclasses get the right parameters. So where does your code go?

You've already seen part of the answer: pane methods build the widget tree in the panes you defined in TAB_LAYOUT. The other half is ip_* hooks — they're how you talk to the game loop:

  • ip_setup_early(self, ip) — runs once before this pane's widgets are built. Initialize state your pane builders will read.
  • ip_setup(self, ip) — runs once after the widget tree is built. Initialize game/animation state.
  • ip_activated(self, ip) — runs each time this pane (or form) becomes visible.
  • ip_think(self, ip) — runs every frame. Update state, run physics, decide things.
  • ip_draw(self, ip) — runs every frame, before widgets draw. Custom rendering behind the UI.
  • ip_draw_hud(self, ip) — runs every frame, after widgets draw. Overlays, FPS counters, anything on top.

The framework calls these; you override them. Together with pane methods, that's the whole split:

If it lays out widgets, it goes in a pane method. If it ticks, decides, animates, or paints custom graphics, it goes in an ip_* hook.

Run SmokeTest Click Bouncing Ball Try scaffolding 'Bare Bones' BareBones.py is created

from ipui import *
import pygame

class BouncingBall(_BaseTab):
    
    def arena(self, parent):                         # ← pane method: builds the UI
        Title(parent, text="Bouncing Ball")
        card = Card(parent, scroll_v=True)
        CodeBox(card, data=__file__)

    def ip_setup(self, ip):                          # ← runs once
        self.ball_x,  self.ball_y  = 0.5, 0.5        # start in the middle (normalized)
        self.ball_dx, self.ball_dy = 0.4, 0.3        # velocity (normalized units / sec)

    def ip_think(self, ip):                          # ← runs every frame
        self.ball_x += self.ball_dx * ip.dt          # ip.dt = seconds since last frame
        self.ball_y += self.ball_dy * ip.dt
        self.bounce_off_walls()

    def ip_draw(self, ip):                           # ← custom rendering
        pos = ip.to_screen(self.ball_x, self.ball_y) # normalized → screen pixels
        r   = ip.scale_y(0.02)                       # normalized radius → pixels
        pygame.draw.circle(ip.surface, (255, 160, 40), pos, r)

    def bounce_off_walls(self):
        if self.ball_x < 0: self.ball_dx =  0.4
        if self.ball_x > 1: self.ball_dx = -0.4
        if self.ball_y < 0: self.ball_dy =  0.3
        if self.ball_y > 1: self.ball_dy = -0.3

Notice ball_x and ball_y are normalized: 0 is the left/top edge, 1 is the right/bottom edge. IPUI worries about the real pixel resolution.


The ip Parameter

Every lifecycle hook receives a single argument: ip. It's the IPUI Service Portal — one object that gives you everything you need. Type ip. in your IDE and autocomplete shows every attribute and method, organized by family.

You already used it in the example above: ip.dt for frame timing, ip.to_screen() to convert normalized coordinates to pixels, ip.scale_y() to scale a radius. Without the portal, that same ip_draw looks like this:

# ── Without ip (spelunking) ──────────────────────────────────────
def ip_draw(self, ip):
    arena = self.form.tab_strip.panes[1].rect             # find the canvas by hand
    sx    = arena.left + int(self.ball_x * arena.width)   # offset + scale manually
    sy    = arena.top  + int(self.ball_y * arena.height)
    r     = int(0.02 * arena.height)
    pygame.draw.circle(ip.surface, (255, 160, 40), (sx, sy), r)

# ── With ip (portal) ─────────────────────────────────────────────
def ip_draw(self, ip):
    pos = ip.to_screen(self.ball_x, self.ball_y)
    r   = ip.scale_y(0.02)
    pygame.draw.circle(ip.surface, (255, 160, 40), pos, r)

Three lines. No spelunking. No manual math. Resolution-independent. The portal absorbs the coordinate plumbing so you can focus on what you're actually drawing.

The full set of ip attributes and methods is covered in The ip Service Portal below. The full set of hooks and when each fires is in Lifecycle Hooks.


Updating the UI

IPUI gives you three ways to keep the UI in sync with state. Mix them freely.

  • Imperative — store references, update by hand. Surgical and direct.
  • Pipeline — bind widgets to keys; write to a key and every bound widget updates. No widget references, no callbacks, no per-update code.
  • Reactive — declare derived values in BINDINGS; the framework recomputes when triggers change.

Most real apps use all three: imperative for one-off direct updates, pipeline for state that drives many widgets (or crosses tabs), reactive for derived display logic. Pick whichever fits each call site.


Imperative — direct, surgical

Store widget references, update them by hand:

    # REPLACE METHOD ARENA
    def arena(self, parent):                                # ← pane method: builds the UI
        self.lbl_quadrant  = Body(parent, "Quadrant: —")    # NOTE: Now we are storing reference to the widgets
        self.lbl_direction = Body(parent, "Direction: —")
        self.lbl_warning   = Body(parent, "")


    def ip_think(self, ip):
        self.ball_x += self.ball_dx * ip.dt                 # No change
        self.ball_y += self.ball_dy * ip.dt                 # No change
        self.bounce_off_walls()                             # No change
        
        # Imperative Update
        self.lbl_quadrant .set_text(f"Quadrant: {self.compute_quadrant()}")
        self.lbl_direction.set_text(f"Direction: {self.compute_direction()}")
        self.lbl_warning  .set_text(self.compute_warning())

    # No change to ip_setup and ip_draw
        
    # Add these three methods
    def compute_quadrant_text (self, ball_x,  ball_y):  return f"Quadrant: {('NW' if self.ball_y<0.5 else 'SW') if self.ball_x<0.5 else ('NE' if self.ball_y<0.5 else 'SE')}"
    def compute_direction_text(self, ball_dx, ball_dy): return f"Direction: {'→' if self.ball_dx>0 else '←'}{'↓' if self.ball_dy>0 else '↑'}"
    def compute_warning_text  (self, ball_x,  ball_y):  return "⚠ OMG we are going to crash!" if min(self.ball_x, self.ball_y, 1-self.ball_x, 1-self.ball_y) < 0.05 else ""

Every update is an explicit line you can grep for and breakpoint on. Great when one widget reflects one piece of state.


Pipeline

The pipeline is a centralized key-value store. Write to it, and any widget that declared a dependency is automatically updated:

# Write
self.form.pipeline_set("training_active", True)

# Read
active = self.form.pipeline_read("training_active")

The pipeline also pushes values back to source widgets — if you call pipeline_set("my_key", "") all widgets with pipeline_key="my_key" updates its displayed text automatically.

Seeding initial values: declare PIPELINE_DEFAULTS on your form to populate the pipeline at startup:

class MyApp(_BaseForm):             # Be sure to put this in form - not a _BaseTab file.
    PIPELINE_DEFAULTS = {
        "training_active": False,
        "epoch":           3141596,
        "config_valid":    True,
    }

Reactive — declare relationships, let the framework propagate

BINDINGS is a class-level dict that wires pipeline keys to widget properties. When a key changes, the framework calls your compute method and applies the result — no manual update code needed:

from ipui import *
import pygame

class BouncingBall(_BaseTab):

    BINDINGS = {
        "lbl_quadrant":  {"property": "text", "compute": "compute_quadrant",  "triggers": ["ball_x", "ball_y"]},
        "lbl_direction": {"property": "text", "compute": "compute_direction", "triggers": ["ball_dx", "ball_dy"]},
        "lbl_warning":   {"property": "text", "compute": "compute_warning",   "triggers": ["ball_x", "ball_y"]},
    }

    # replace Arena with this - Reactive uses named widgets instead of instance references
    def arena(self, parent):
        Body(parent, "Quadrant: —"  , name="lbl_quadrant" ) # NOTE: No self.lbl_quadrant
        Body(parent, "Direction: —" , name="lbl_direction") # NOTE: No self.lbl_direction
        Body(parent, ""             , name="lbl_warning"  ) # NOTE: No self.lbl_warning

        
    def ip_setup(self, ip):                          # ← runs once
        self.ball_x,  self.ball_y  = 0.5, 0.5        # start in the middle (normalized)
        self.ball_dx, self.ball_dy = 0.4, 0.3        # velocity (normalized units / sec)

        
    def ip_think(self, ip):
        self.ball_x += self.ball_dx * ip.dt                 # No change
        self.ball_y += self.ball_dy * ip.dt                 # No change
        self.bounce_off_walls()                             # No change

        # Replace Imperative update with Reactive update.   # Set pipeline values
        self.form.pipeline_set("ball_x",  self.ball_x)      # framework sees the change,
        self.form.pipeline_set("ball_y",  self.ball_y)      # calls the right compute methods,
        self.form.pipeline_set("ball_dx", self.ball_dx)     # and updates the widgets.
        self.form.pipeline_set("ball_dy", self.ball_dy)


    def ip_draw(self, ip):                           # ← custom rendering
        pos = ip.to_screen(self.ball_x, self.ball_y) # normalized → screen pixels
        r   = ip.scale_y(0.02)                       # normalized radius → pixels
        pygame.draw.circle(ip.surface, (255, 160, 40), pos, r)

        
    def compute_quadrant (self, ball_x, ball_y):  return f"Quadrant: {('NW' if ball_y<0.5 else 'SW') if ball_x<0.5 else ('NE' if ball_y<0.5 else 'SE')}"
    def compute_direction(self, ball_dx, ball_dy): return f"Direction: {'Right ' if ball_dx>0 else 'Left '}{'Down' if ball_dy>0 else 'Up'}"
    def compute_warning  (self, ball_x, ball_y):  return "I don't want to hit the wall" if min(ball_x, ball_y, 1-ball_x, 1-ball_y) < 0.05 else ""


    def bounce_off_walls(self):
        if self.ball_x < 0: self.ball_dx =  0.4
        if self.ball_x > 1: self.ball_dx = -0.4
        if self.ball_y < 0: self.ball_dy =  0.3
        if self.ball_y > 1: self.ball_dy = -0.3

Feels like a little more work doesn't it? But add a fourth widget? One new entry in BINDINGS. ip_think doesn't grow.


Which one should you use?

Honest answer: this example is roughly a tie. You have four pipeline_set calls vs. three set_text calls — neither version is obviously cleaner at this scale. Reactive starts to win when state drives many widgets, or when several places update the same state. Imperative stays clearer when one widget reflects one piece of state and you want a single named method everyone calls. Mix them in the same tab. IPUI doesn't have an opinion on which paradigm is "correct" — only that you should have the choice and that both should be cheap.


Mix all three freely. Each one earns its keep: pipeline shines when one piece of state drives many widgets — including across tabs — and you don't want to maintain explicit wiring. Reactive shines when widget display depends on combinations of state. Imperative shines for surgical one-off updates. Same engine underneath, three access patterns on top.

Reactive vs imperative pipeline screenshot


The IPUI WAY

We make the right path the easy path.
We think what would be the easiest api to do 'whatever' and build that.

  • Simple things should be trivial
  • Missing structure should be fixable, not fatal
  • Scaling out should feel natural
  • Boilerplate should be eliminated or scaffolded by IPUI, not copied around by hand
  • Learning should happen by playing with real, running examples

That's why the Full Showcase template gives you a fully functional widget gallery — click, rearrange, copy-paste, and keep building. Start stealing code before you've written your first line.

No event loop setup. No manual sizing. No coordinate math. IPUI handles the Pygame lifecycle, layout, rendering, and event dispatch automatically.

The ip Service Portal

Identity

Attribute Type Description
ip.form _BaseForm Active Form instance
ip.form_name str Name of the active form
ip.tab _BaseTab Active Tab instance (or the form, in tabless mode)
ip.tab_name str Name of the active tab
ip.is_active_tab bool Is this the visible tab?

Timing

Attribute Type Description
ip.dt float Seconds since last frame
ip.fps int Current frames per second
ip.frame int Monotonically increasing frame counter
ip.elapsed float Total seconds since app started

Geometry

Attribute Type Description
ip.rect_pane Rect Your drawing canvas — the None pane slot, or the whole pane if no None slot exists
ip.rect_tab_area Rect The entire tab content row (all pane slots combined)
ip.rect_screen Rect The full pygame surface

Use ip.rect_pane for all custom rendering in ip_draw and ip_draw_hud. No need for spelunking through the widget tree — the framework finds your canvas for you.

Coordinate Helpers

Work in normalized coordinates (0.0–1.0) and let IPUI handle the pixel math:

Method Description
ip.to_screen(nx, ny) Normalized (0–1) → screen pixel tuple within rect_pane
ip.to_local(sx, sy) Screen pixels → normalized (0–1) within rect_pane
ip.scale_x(n) Normalized width → pixel width
ip.scale_y(n) Normalized height → pixel height
ip.local_to_screen(x, y) Pane-local pixel coords → screen coords
ip.screen_to_local(x, y) Screen coords → pane-local pixel coords

Mouse

Attribute / Method Type Description
ip.mouse_x int Mouse x position (screen)
ip.mouse_y int Mouse y position (screen)
ip.mouse_pos tuple Mouse (x, y) tuple
ip.mouse_wheel int Scroll wheel delta this frame
ip.mouse_down(Mouse.LEFT) bool Is the button held this frame?
ip.mouse_pressed(Mouse.LEFT) bool Was the button just pressed this frame? (edge detect)
ip.mouse_released(Mouse.LEFT) bool Was the button just released this frame?
ip.mouse_inside(widget) bool Is the mouse inside this widget's rect?
ip.mouse_inside_pane() bool Is the mouse inside rect_pane?
ip.mouse_inside_content() bool Is the mouse inside rect_tab_area?
ip.mouse_hits(rect) bool Is the mouse inside an arbitrary rect?
ip.mouse_local_pos() tuple Mouse position relative to rect_pane
ip.mouse_local_pos(widget) tuple Mouse position relative to a widget
ip.mouse_local_x() int Mouse x relative to rect_pane
ip.mouse_local_y() int Mouse y relative to rect_pane

Constants: Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT. Import with from ipui import *.

Keyboard

Attribute / Method Type Description
ip.mod_shift bool Shift held?
ip.mod_ctrl bool Ctrl held?
ip.mod_alt bool Alt held?
ip.key_down(Key.SPACE) bool Is this key held this frame?
ip.key_pressed(Key.SPACE) bool Was this key just pressed this frame?
ip.key_released(Key.SPACE) bool Was this key just released this frame?

Constants live on the Key class — autocomplete shows everything. Examples: Key.LEFT, Key.RIGHT, Key.UP, Key.DOWN, Key.SPACE, Key.RETURN, Key.ESCAPE, Key.TAB, Key.BACKSPACE, Key.AKey.Z, Key.NUM_0Key.NUM_9, Key.F1Key.F12, Key.HOME, Key.END, Key.PAGEUP, Key.PAGEDOWN, Key.DELETE.

Rendering

Attribute Type Description
ip.surface Surface The pygame draw surface
ip.events list All pygame events this frame
ip.unhandled list Events the UI did not consume

Cache

A simple key-value scratch pad. Persists across tabs but has no connection to the reactive pipeline — it does not trigger derives or update widgets.

Method Description
ip.cache_get(key, default=None) Read a value
ip.cache_set(key, value) Store a value
ip.cache_has(key) Check if key exists
ip.cache_del(key) Remove a key

For reactive state, use self.form.pipeline_set() / self.form.pipeline_read().

For scratch data (animation counters, drag state, accumulators), use ip.cache.

Invalidation (scaffolded for future optimization)

Method Description
ip.request_redraw() Mark pane as needing repaint
ip.request_layout() Mark pane as needing layout recalc

Currently IPUI renders every frame, so these are effectively no-ops. They exist so your code will work unchanged when dirty-flag optimization lands.


State Machine

ip.state is a built-in state machine available everywhere — panes, forms, hooks. Each state pairs a name with a delegate (a method to run while in that state). States can chain to a follow-up state and auto-advance after a duration.

  • Register a method for each state.
  • The state machine tracks what state you're in.
  • The state machine calls that method each frame.
  • You can set up a state to last for a fixed time or switch it manually in code.

Register states with add(), transition with go():

Excerpt — see Breakout in docs() for the full runnable version

class Breakout(_BaseTab):
    def ip_setup(self, ip):
        ip.state.add("DEMO"     , self.state_demo)
        ip.state.add("READY"    , self.state_ready)
        ip.state.add("PLAYING"  , self.state_playing)
        ip.state.add("LEVEL_UP" , None,    "READY",     1.5)   # auto-advance after 1.5s
        ip.state.add("GAME_OVER", None,    "DEMO",      2.5)   # auto-advance after 2.5s
        ip.state.go("DEMO")


    def state_demo(self):       ...    # called every frame while in DEMO
    def state_ready(self):      ...    # called every frame while in READY
    def state_playing(self):    ...    # called every frame while in PLAYING

When a state has a duration, the engine counts down automatically and transitions to the named follow-up state when the timer expires. A None delegate means "do nothing this frame" — useful for pure timed transitions like flash messages where your draw code reads ip.state.current and renders accordingly.

API:

Method / Property Description
ip.state.add(name, delegate, next=None, duration=0) Register a state
ip.state.go(name, duration=None) Transition to a state (override duration optional)
ip.state.next_state() Follow the registered chain to the next state
ip.state.is_("NAME") True if current state matches
ip.state.current Current state name (or None)

Branch your logic on the current state:

def ip_think(self, ip):
    if ip.state.is_("READY"):
        if ip.mouse_pressed(Mouse.LEFT):
            ip.state.go("PLAYING")
        return

    if ip.state.is_("PLAYING"):
        self.run_physics(ip)

Simple Default — if you just need one, easiest to use 'ip.state'.

Multiple state machines — if you need more than one you can name them.

ip.state("combat").add("IDLE", self.combat_idle)
ip.state("combat").go("IDLE")
ip.state("ui").go("MENU_OPEN")

ip.state and ip.state() both return the default machine. ip.state("name") returns a named one, created on first access.

🚧 Coming soon: declarative state config via a STATES class dict and ip.state.configure(self.STATES), plus ip.state.in_("A", "B") for multi-name membership tests.


Lifecycle Hooks

IPUI gives you six hooks into the application lifecycle. Each one fires at a specific moment, has a clear job, and works identically whether you're on a _BaseTab or a _BaseForm.

The ip Hooks

ip_setup_early(self, ip) — Runs once, before the pane's widgets are built. This is the place to set self.X = ... for any state your pane builders will read while constructing widgets — column modes, file paths, configuration, lookup tables. By the time this fires, self.form and self.widgets are wired, but no widgets in this tab exist yet.

def ip_setup_early(self, ip):
    self.column_mode = "flex"
    self.db_path     = self.form.pipeline_read("db_path")
    self.catalog     = load_widget_catalog()

ip_setup(self, ip) — Runs once, when the pane is first created. Initialize your state here: positions, velocities, counters, loaded assets, state machine configuration. By the time this fires, self.form, self.ip, and the widget tree are fully wired.

def ip_setup(self, ip):
    self.ball_x  = 0.5
    self.ball_y  = 0.5
    self.speed   = 0.4
    self.score   = 0
    ip.state.add("READY"  , self.state_ready)
    ip.state.add("PLAYING", self.state_playing)
    ip.state.go("READY")

ip_activated(self, ip) — Runs every time this pane or form becomes visible. On a _BaseTab, this fires when the user switches to your tab, and also on the initial load after ip_setup. On a _BaseForm, this fires when IPUI.show() or IPUI.back() brings the form to the front.

Use it to refresh data that might have changed while you were off-screen, restart animations, or sync state from the pipeline.

def ip_activated(self, ip):
    self.refresh_leaderboard()
    self.resume_particle_effects()

Note on ip inside ip_activated: Identity (ip.form, ip.tab, ip.tab_name, ip.is_active_tab) and geometry (ip.rect_pane, ip.rect_tab_area) are correct when this hook fires. Per-frame fields like ip.dt, ip.events, and ip.surface reflect the last completed frame — ip_activated runs at lifecycle transitions, not inside the per-frame loop, so use the per-frame fields with care here.

ip_think(self, ip) — Runs every frame. This is your logic tick: state machines, physics, AI, input polling, data checks. We recommend drawing in ip_draw() but it's a free country (and really doesn't hurt anything).

By default, ip_think only fires on the active pane. Set THINK_ALWAYS = True on your _BaseTab subclass if you need background processing even when the tab isn't visible (useful for simulations that shouldn't pause when the user switches tabs).

def ip_think(self, ip):
    if ip.state.is_("PLAYING"):
        self.ball_x += self.ball_dx * ip.dt
        self.ball_y += self.ball_dy * ip.dt
        if self.ball_y > 1.0:
            ip.state.go("GAME_OVER")

ip_draw(self, ip) — Runs every frame, before the widget tree draws. This is where you paint game worlds, backgrounds, visualizations — anything that should appear behind your widgets.

def ip_draw(self, ip):
    pos = ip.to_screen(self.ball_x, self.ball_y)
    r   = ip.scale_y(self.ball_r)
    pygame.draw.circle(ip.surface, (255, 160, 40), pos, r)

ip_draw_hud(self, ip) — Runs every frame, after the widget tree draws. Overlays, cursors, FPS counters, debug text — anything that should appear on top of everything else.

def ip_draw_hud(self, ip):
    font = Style.FONT_DETAIL
    surf = font.render(f"FPS: {ip.fps}", True, Style.COLOR_TEXT_ACCENT)
    ip.surface.blit(surf, (10, 10))

Execution Order

Every frame follows this sequence:

ip_think       →  your logic runs
Layout pass    →  widget tree measures and positions
ip_draw        →  you paint behind the widgets
Widget render  →  the UI draws itself
ip_draw_hud    →  you paint on top of everything

ip_setup_early, ip_setup, and ip_activated are not per-frame — they fire at lifecycle transitions.

Using Hooks on a Pane

Override any hook directly on your _BaseTab subclass:

class BouncingBall(_BaseTab):
    def ip_setup(self, ip):
        self.x, self.y   = 0.5, 0.5
        self.dx, self.dy = 0.4, 0.3

    def ip_activated(self, ip):
        self.x, self.y   = 0.5, 0.5      # reset position on tab switch

    def ip_think(self, ip):
        self.x += self.dx * ip.dt
        self.y += self.dy * ip.dt
        if self.x < 0 or self.x > 1: self.dx = -self.dx
        if self.y < 0 or self.y > 1: self.dy = -self.dy

    def ip_draw(self, ip):
        pos = ip.to_screen(self.x, self.y)
        pygame.draw.circle(ip.surface, (255, 160, 40), pos, ip.scale_y(0.02))

    def ip_draw_hud(self, ip):
        surf = Style.FONT_DETAIL.render(f"FPS: {ip.fps}", True, Style.COLOR_TEXT_ACCENT)
        ip.surface.blit(surf, (10, 10))

Using Hooks on a Form

Override on your _BaseForm subclass for app-wide logic. Form hooks fire in addition to pane hooks — the form thinks first, then the active pane thinks.

class MyApp(_BaseForm):
    def ip_setup(self, ip):
        self.global_timer = 0

    def ip_activated(self, ip):
        # Fires when IPUI.show() brings this form to the front
        self.refresh_global_state()

    def ip_think(self, ip):
        self.global_timer += ip.dt

Pane render hooks only fire for the active pane. Form render hooks fire once per frame before/after the active pane render hook.

Background Processing

By default, all hooks only run on the active tab.

  • ip_setup runs the first time a tab is activated.
  • ip_activated runs when the user returns to the tab.
  • Obviously, no point in the two draws if the tab isn't visible.
  • But with ip_think, you have a choice.

Use THINK_ALWAYS to switch that default behavior. If your pane runs a simulation or background process that shouldn't pause when the user switches tabs, set THINK_ALWAYS to True and ip_think will run regardless of active tab:

class TrainingMonitor(_BaseTab):
    THINK_ALWAYS = True

    def ip_think(self, ip):
        if not ip.is_active_tab:
            self.training_step()     # keep training even when not visible
            return
        self.update_charts()         # only update visuals when visible

Quick Reference

Hook Receives ip? When it fires Fires on inactive pane?
ip_setup_early Yes Once, at creation before Widgets N/A — only fires once
ip_setup Yes Once, at creation after Widgets N/A — only fires once
ip_activated Yes Each time pane/form becomes visible N/A — fires on activation
ip_think Yes Every frame Only with THINK_ALWAYS = True
ip_draw Yes Every frame, before widgets Active pane only
ip_draw_hud Yes Every frame, after widgets Active pane only

Widget Catalog

Text Hierarchy

IPUI scales text proportionally to your physical screen. You pick semantic roles, not font sizes:

Widget Purpose Example
Banner App title. Banner(parent, "NeuroForge", glow=True)
Title Pane/section header. Title(parent, "Settings", glow=True)
Heading Subsection label. Heading(parent, "Hyperparams:")
Body The workhorse. Most text is this. Body(parent, "Configure your model.")
Detail Fine print, timestamps. Detail(parent, "Last updated: 2:30pm")

All text widgets support glow=True (molten-orange forge effect) and text_align=CENTER or RIGHT.

Layout Containers

  • IPUI widgets are not divided into rigid categories like "containers" and "leaves."
  • Shared behaviors live in the framework layer.
  • Widgets become what they are by opting into the behaviors they need.

Any widget can be a container/parent. Drop an icon inside a button; that button is a parent. But these widgets are setup to make nice containers for you

Widget Direction Chrome Usage
Row Horizontal None Row(parent, justify_spread=True)
Col Vertical None Col(parent)
CardRow Horizontal Beveled, filled CardRow(parent, flex_width=1)
CardCol Vertical Beveled, filled CardCol(parent, scroll_v=True)
Card Vertical Beveled, filled Card(parent, flex_height=1)

Row/Col are invisible structure. CardRow/CardCol/Card have a background and beveled edges.

Interactive Widgets

Button

Button(parent, "Launch",
    color_bg = Style.COLOR_BUTTON_CTA,
    on_click = self.launch_training,
    flex_width = 2)

Automatic hover brightening, press bevel inversion, disabled dimming using HSL math.

Note: Automatically generating hover/disabled colors saves a lot of work but isn't always perfect. You can always override the automatically generated colors.

TextBox

TextBox(parent,
    placeholder  = "Enter learning rate",
    pipeline_key = "learning_rate",
    on_change    = self.rate_changed)

With pipeline_key, writes to the pipeline on every keystroke. With on_change, you get a callback too. Use both, either, or neither.

SelectionList

SelectionList(parent,
    data          = {"SGD": {...}, "Adam": {...}, "RMSProp": {...}},
    pipeline_key  = "optimizer",
    single_select = True,
    on_change     = self.optimizer_changed)

DropDown

DropDown(parent,
    data          = {"SGD": {}, "Adam": {}, "RMSProp": {}},
    pipeline_key  = "optimizer",
    single_select = True)

PowerGrid

grid = PowerGrid(parent, name="results_grid")
grid.set_data(rows, columns=["Run", "Accuracy", "Loss"])
grid.set_column_max("Run", 200)
grid.set_page_size(50)
grid.on_row_click(self.on_row_selected, column="Run")

PowerGrid also accepts SQL databases directly:

grid.set_data("path/to/database.db",
    query="SELECT run_id, accuracy FROM batch_history ORDER BY accuracy DESC")

Or load an entire table:

grid.set_data("path/to/database.db", table="batch_history")

Sorting works across pages — sort the full dataset, then paginate the sorted result. PowerGrid screenshot

Chart

chart = Chart(parent, flex_width=1, flex_height=1)
chart.set_data(
    lines   = {"Train Loss": [(0, 0.9), (1, 0.7), (2, 0.5)],
               "Val Loss":   [(0, 0.95),(1, 0.75),(2, 0.6)]},
    x_label = "Epoch",
    y_label = "Loss"
)

Layout System

IPUI uses a flex-inspired layout. Set flex_width or flex_height to a weight; the remaining space is distributed proportionally:

row = Row(parent)
Col(row, flex_width=1)   # gets 1/3 of width
Col(row, flex_width=2)   # gets 2/3 of width

Unset (or 0) means the widget takes its natural size. No explicit pixel math required.

Scrollable containers:

CardCol(parent, scroll_v=True, flex_height=1)

Scrollable containers clip and scroll their children automatically. Scrollbars support both mouse wheel and click-and-drag.


Tabs and Panes

Tab layout is declared in one dict on your form:

class MyApp(_BaseForm):
    TAB_LAYOUT = {
        "Config":  ["settings", "hyperparams"],
        "Results": ["chart",    "grid"],
        "Log":     ["log"],
    }

Each value is a list of method names. IPUI discovers the corresponding _BaseTab subclass by tab name (matching a Python file in the same directory), calls those methods to build each pane, and arranges them side-by-side.

# Config.py
class Config(_BaseTab):
    def settings(self, parent):
        Title(parent, "Settings")
        ...

    def hyperparams(self, parent):
        Title(parent, "Hyperparameters")
        ...

Canvas Panes

Use None in the pane list to create a transparent, chrome-free drawing canvas:

TAB_LAYOUT = {
    "Game": ["controls", None, "scoreboard"],
}

The None slot becomes ip.rect_pane — your game's arena. Draw in ip_draw, read mouse position with ip.mouse_local_x(), convert coordinates with ip.to_screen(). Zero framework spelunking required.

Breakout demo screenshot

Pane Weights

Control relative pane widths with tuples:

TAB_LAYOUT = {
    "Dashboard": [("sidebar", 1), ("main", 3)],
}

Bare strings default to weight 1. Mix freely: ["info", ("detail", 3)].

Cross-Tab Pane Sharing

Reuse pane methods from other tabs with dot notation:

TAB_LAYOUT = {
    "Armory":  ["match_hints", "match_settings", None],
    "Pro":     ["Armory.match_settings", "Forge.workbench", "Forge.preview"],
}

Tab Control at Runtime

self.form.switch_tab("Results")
self.form.hide_tab("Log")
self.form.show_tab("Log")
self.form.set_pane(1, self.rebuild_results)
self.form.refresh_pane(1)

Early-load tabs (pre-built at startup instead of on first click):

class MyApp(_BaseForm):
    tab_early_load = ["Config", "Results"]

Hidden tabs (initially hidden, shown later via show_tab):

class MyApp(_BaseForm):
    tab_hidden = ["Colosseum"]

Guarding Tab Switches with tab_on_change

Sometimes you need to block a tab switch — for example, "the user hasn't picked a project yet, so don't let them leave Home." That's the job of tab_on_change.

Set tab_on_change to the name of a method on your form. IPUI calls it before every tab switch and lets it veto the change by returning False:

class FormNeuroForge(_BaseForm):
    TAB_LAYOUT     = {"Home": ["..."], "Forge": ["..."], "Pro": ["..."]}
    tab_on_change  = "guard_tab_switch"

    def guard_tab_switch(self, name, current):
        if current == "Home" and not self.has_active_project():
            self.show_modal("Pick a project first!")
            return False           # ← veto: tab does NOT switch
        return True                 # ← allow the switch

Signature: method(name, current)name is the destination tab, current is the tab the user is leaving.

Return value:

  • False → veto. The tab strip stays where it is. No ip_activated fires.
  • Any other value (True, None, missing return) → switch proceeds normally.

This is a different superpower from ip_activated. tab_on_change is a gate — it can stop a switch before it happens. ip_activated is a welcome mat — it runs after the switch is already in motion. Both can coexist on the same form.

For the full lifecycle of a tab switch — including exactly when each hook fires — see Appendix C.


Tabless Mode

Not every app needs tabs. Games, visualizations, single-screen tools — sometimes you just want a window and some widgets.

Skip TAB_LAYOUT entirely. Build widgets in build(). Use the same lifecycle hooks you already know.


Minimal Example

from ipui import *

class MyApp(_BaseForm):
    def build(self):
        Banner(self, "My App", glow=True, text_align=CENTER)
        Title(self, "No tabs. No panes. Just widgets.", text_align=CENTER)
        Body(self, "Everything lives right here.", text_align=CENTER)
        Button(self, "Do Something", color_bg=Style.COLOR_BUTTON_CTA, on_click=self.do_something)

    def do_something(self):
        self.show_modal("It works!")

if __name__ == "__main__":
    show(MyApp)

No TAB_LAYOUT. No _BaseTab. One class, one file, name it whatever you want.


Tabless Lifecycle Hooks are the Same

The same hooks work on a tabless form as on any _BaseTab pane:

    from ipui import *
    import pygame

    class Asteroids(_BaseForm):
        def build(self):
            self.lbl_score = Title(self, "Score: 0")

        def ip_setup(self, ip):
            self.ship_x  = 0.5
            self.ship_y  = 0.5
            self.speed   = 0.4
            self.bullets = []
            ip.state.add("READY"    , self.state_ready)
            ip.state.add("PLAYING"  , self.state_playing)
            ip.state.add("GAME_OVER", None, "READY", 2.5)
            ip.state.go("READY")

        def state_ready(self):    pass
        def state_playing(self):  pass

        def ip_think(self, ip):
            if ip.state.is_("PLAYING"):
                self.ship_x += self.speed * ip.dt
                self.lbl_score.set_text(f"Score: {len(self.bullets)}")

        def ip_draw(self, ip):
            pos = ip.to_screen(self.ship_x, self.ship_y)
            pygame.draw.circle(ip.surface, (255, 160, 40), pos, ip.scale_y(0.02))

        def ip_draw_hud(self, ip):
            font = Style.FONT_DETAIL
            surf = font.render(f"FPS: {ip.fps}", True, Style.COLOR_TEXT_ACCENT)
            ip.surface.blit(surf, (10, 10))

    if __name__ == "__main__":
        show(Asteroids)

Every hook — ip_setup, ip_activated, ip_think, ip_draw, ip_draw_hud — works identically whether it lives on a _BaseForm or a _BaseTab. Move code between the two freely.


When to Use Tabless vs Tabbed

Scenario Approach
Quick prototype, single screen Tabless — one class, build()
Game or visualization Tabless — full hook access, no tab chrome
Multi-view app Tabbed — TAB_LAYOUT with _BaseTab files
One-file demo with tabs Tabbed — builder methods on the form

Tabless is the on-ramp. Tabs are the highway. Both use the same engine.


Wanna nerd out with me????

🧠 Engineering for Fitts's Law IPUI doesn't just put pixels/ on the screen; it optimizes for the human hand and eye. Every interaction is designed to minimize cognitive load and physical movement.

🎯 The Prime Pixel & The Zero-Distance Pin We leverage Fitts's Law — which states that the time to acquire a target is determined by distance and size — to make your workflow feel instantaneous.

  • The Prime Pixel: Our "Super Tips" utilize the most valuable real estate on your screen: the pixel where your cursor already sits.
  • Zero-Distance Acquisition: By spawning the Pin button directly under the mouse after a brief intent delay, we reduce the movement distance (D) to zero. This makes pinning information a near-instant action.

⏲️ Temporal Guardrails To prevent accidental interactions (misclicks), IPUI implements Temporal Buffering:

  • Hover State (1.5s): Standard tooltip appears — non-invasive and follows the mouse.
  • Engagement (0.5s): The Super Tip expands, providing deep contextual data.
  • Action Readiness (0.5s later): The Pin button manifests. This staggered entry ensures that a click meant for the underlying button isn't accidentally "stolen" by the tooltip. Super Tooltip screenshot

Construction-Time Safety

IPUI catches mistakes when you make them, not when users hit them:

Mistake Error raised
Override __init__ in a widget TypeError at class definition
Override __init__ in a pane TypeError at class definition
justify_center AND justify_spread ValueError at construction
text_align='x' ValueError at construction
widgets["typo"] RuntimeError listing valid names
on_click_me(non_callable) TypeError at registration
on_click_me(func_with_params) ValueError at registration

IPUI error messages always announce themselves with the same banner: Houston we have a problem! — easy to spot in a long traceback.


Inline Parent - Construction is Attachment

Want to wrap a Row in a Plate? Just do it:

right = Row(Plate(header, flex_width=1), flex_width=1, pad_y=0)

Same as:

plate_wrapper = Plate(header, flex_width=1)
right = Row(plate_wrapper, flex_width=1, pad_y=0)

Two Paths to on_click

Any widget can be made clickable. There are two ways to wire the callback, and the difference is intentional:

  • on_click= (constructor kwarg) — pass the callback when you build the widget. Fast, no validation. You're trusted to pass a zero-arg callable.
  • widget.on_click_me(callback) — register the callback after construction. Validates that the callback is callable and takes zero arguments, raising at registration time if not.

Both end up at the same place (self.on_click). Use the kwarg for inline construction, use on_click_me when you're wiring a handler after the fact and want the safety net.

⚠️ Don't rename one to the other. They share a target attribute (self.on_click); collapsing the names creates a method-shadows-attribute collision that surfaces as a "not callable" error at click time.


Styling and Theming

All styling lives in Style. Import and use constants — don't hard-code colors or sizes:

from ipui import * 

Button(parent, "Go"     , color_bg  =Style.COLOR_BUTTON_CTA)
Body(parent,   "Status" , font      =Style.FONT_BODY)

Color constants: COLOR_BACKGROUND, COLOR_MODAL_BG, COLOR_PANEL_BG, COLOR_CARD_BG, COLOR_TEXT, COLOR_TEXT_SECONDARY, COLOR_TEXT_MUTED, COLOR_TEXT_ACCENT, COLOR_BORDER, COLOR_BORDER_SUBTLE, COLOR_BUTTON_BG, COLOR_BUTTON_CTA (green), COLOR_BUTTON_DANGER (red), COLOR_BUTTON_SECONDARY (blue), COLOR_BUTTON_ACCENT (orange), COLOR_BUTTON_WARNING, COLOR_CODE_BG

Font constants: FONT_BANNER, FONT_TITLE, FONT_HEADING, FONT_BODY, FONT_DETAIL, FONT_MONO

Tokens: TOKEN_PAD, TOKEN_PAD_TIGHT, TOKEN_GAP, TOKEN_GAP_TIGHT, TOKEN_BORDER, TOKEN_SCROLLBAR, TOKEN_CORNER_RADIUS

Screen: SCREEN_WIDTH (default 1900), SCREEN_HEIGHT (default 900), FONT_SCALE (default 0.369)


Debug Tools

IPUI ships with built-in developer tools so you never have to guess what the layout engine is doing.

F12 — Professional Grade Debug Tools

Press F12 to open the IPUI X-Ray. Tabs include:

  • Tree — Live view of every widget: flex settings, minimum sizes, actual rects. Click any row to inspect all properties. Copy the full tree to clipboard for sharing.
  • Magic — Live view of all reactive pipeline keys, values, and registered derives.
  • Reference — Searchable framework documentation with table of contents, built from the source code itself.
  • Layout — Layout debugging surface (under active development).
  • Overlay — Diagnostic overlay controls.

Widget tree debug inspector

F11 — Layout Overlay

Press F11 to toggle a translucent overlay that draws every widget's rect directly on your running app. Instantly see padding, gaps, and alignment without opening the inspector.

Layout overlay screenshot

Both tools work on any IPUI app with zero setup — no flags, no config, no imports.


Launching Your App From a Different File

I personally like to have a 'main.py' at the top of the pyramid that at least makes it easy to see where it starts.

import ipui
from myapp import MyApp

ipui.show(MyApp, "My Application")  #MyApp is your _BaseForm class - IMPORT REQUIRED

ipui.show() starts the Pygame loop on the first call. On subsequent calls (from within a running app) it switches the active form — letting you navigate between entirely different screens. Use ipui.back() to return to the previous form.


API Reference

BaseForm Class Attributes

Attribute Type Description
TAB_LAYOUT dict Tab name → list of pane method names
PIPELINE_DEFAULTS dict Initial pipeline keys/values seeded at form creation
tab_early_load list Tab names to pre-build at startup
tab_on_change str Name of method on this form to call before every tab switch. Signature: method(name, current). Return False to veto the switch.
tab_hidden list Tab names initially hidden
pipeline_debug bool Log all pipeline activity to console

BaseForm Methods

Method Description
pipeline_set(key, value) Write to pipeline; triggers derived updates
pipeline_read(key) Read current pipeline value
switch_tab(name) Switch to named tab
set_pane(index, builder, *args, tab_name=None, weight=None, **kwargs) Replace pane content at runtime
refresh_pane(index) Rebuild current pane from its existing builder
hide_tab(name) Hide a tab button
show_tab(name) Show a hidden tab button
get_tab(name) Return cached _BaseTab instance
prepare(name) Force-load a tab's _BaseTab
show_modal(msg, min_seconds=2, work_func=None) Show modal message; optionally run work_func while displayed for at least min_seconds
ip_think(ip) Per-frame logic hook (override for app-wide state)
ip_draw(ip) Pre-render hook (override for backgrounds)
ip_draw_hud(ip) Post-render hook (override for overlays)

_BaseWidget Constructor Parameters

All widgets accept these parameters:

Parameter Type Default Description
parent widget Parent widget (auto-attaches on construction)
text str None Display text
name str None Registers widget in form.widgets
flex_width int 0 Flex weight horizontal (0 = natural size)
flex_height int 0 Flex weight vertical (0 = natural size)
pad int TOKEN_PAD Internal padding
gap int TOKEN_GAP Gap between children
border int TOKEN_BORDER Chrome border thickness
justify_center bool False Center children in available space
justify_spread bool False Spread children evenly
visible bool True Show/hide widget
font Font None Override font
text_align str LEFT LEFT, RIGHT, CENTER
color_bg tuple None Background RGB tuple
glow bool False Molten-orange glow effect
data any None Arbitrary data payload
single_select bool False Enforce single selection (lists/dropdowns)
placeholder str None TextBox placeholder text
initial_value any None Starting value
enabled bool None Can accept a click/focus
on_submit callable None Submit callback
on_change callable None Change callback
on_click callable None Click callback
on_double_click callable None Double-click callback
wrap bool False Allow text wrapping when width-constrained
tab_order int None Focus order for keyboard navigation
early_load bool None Pre-build at startup instead of on-demand
pipeline_key str None Pipeline read/write key
tooltip str None Hover tooltip text
tooltip_class class None Custom tooltip class
scroll_v bool False Enable scrolling for this container
scroll_glow float 0.369 Scrollbar bevel intensity (0 = flat)
start str None CodeBox: start-of-range marker
end str None CodeBox: end-of-range marker
fit_content bool False Size to content width instead of stretching
border_radius int None Rounded corner radius (pixels)

_BaseWidget Methods

Method Description
set_text(text) Update text and rebuild layout
clear_children() Remove all child widgets
on_click_me(callback) Register validated click handler (zero-arg)
tap(func) Run func(self) and return self — inline post-construction helper
display_name Property: human-readable identity (name → text → type)

_BaseTab

Attribute Type Description
BINDINGS dict Reactive derive declarations (see below)
THINK_ALWAYS bool If True, ip_think fires even when this pane isn't visible

Lifecycle hooks (override on your pane):

Method Description
ip_setup_early(ip) One-time setup (runs once before widget tree is built)
ip_setup(ip) One-time setup (runs once after widget tree is built)
ip_activated(ip) Each time the pane becomes visible
ip_think(ip) Per-frame logic. State, physics, AI.
ip_draw(ip) Draw before UI. Game worlds, backgrounds.
ip_draw_hud(ip) Draw after UI. Overlays, cursors, effects.

BINDINGS entry format:

"widget_name": {
    "property": "text",          # or "enabled", or any widget attribute
    "compute":  "method_name",   # method on this _BaseTab
    "triggers": ["key1", "key2"] # pipeline keys that trigger recompute
}

SelectionList Methods

Method Description
get_selected() List of selected item names
get_selected_data() Dict of selected items with their data
set_filter(text) Filter visible items by text
sync_from_pipeline() Sync selection state from pipeline
selected_count Property: number of selected items

DropDown Methods

Method Description
get_selected() List of selected item names
get_selected_data() Dict of selected items with their data
set_filter(text) Filter visible items
set_max_visible(n) How many rows show when dropped down
sync_from_pipeline() Sync from pipeline and update textbox

Chart Methods

Method Description
set_data(lines, x_label, y_label) Update chart data (dirty-flag render)

PowerGrid Methods

Method Description
set_data(data, columns=None) Set grid data (list of lists, list of dicts, or dict of lists)
set_data(path, query="...") Load from SQLite database with a query
set_data(path, table="...") Load an entire SQLite table
set_column_max(col, max_width) Cap a column's pixel width (accepts index or column name)
set_page_size(n) Set rows per page (0 = no pagination)
on_row_click(callback, column=None) Register row click. column=None → dict of row, "name" → that value, int → that index

Dependencies

  • Python 3.10+
  • pygame-ce
  • OPTIONAL: matplotlib (for Chart)

Appendix A: Why IPUI Does Things Differently

IPUI makes choices that look unconventional if you're coming from other UI frameworks. This explains the design intent behind choices that may look unusual if you’re coming from other UI frameworks.

The O(1) Principle

Every framework design decision lives on one side of a line:

  • Framework-side — O(1). Solved once, inside the framework. Every user gets it for free. Zero defect surface. Zero tech debt.
  • User-side — O(N). Every user re-solves it for each usage. Each solution carries a fixed build cost, widens the defect attack surface, and accumulates ongoing tech debt.

IPUI pushes as much as possible to the framework side. When something can be handled once — reliably, invisibly — there's no reason to ask every user to handle it themselves, every time, forever.

This principle drives nearly every decision below.

build(), Not __init__

Here's how custom widgets work in tkinter:

class ScrolledList(tk.Frame):
    def __init__(self, parent, **options):
        tk.Frame.__init__(self, parent, **options)   # ← you must get this right
        self._list = tk.Listbox(self)
        self._scrollbar = tk.Scrollbar(self)
        self._list.pack(side=tk.LEFT)
        self._scrollbar.pack(side=tk.LEFT)

And PyQt:

class PowerBar(QWidget):
    def __init__(self, steps=5, *args, **kwargs):
        super().__init__(*args, **kwargs)             # ← you must get this right
        layout = QVBoxLayout()
        self._bar = _Bar(steps)
        layout.addWidget(self._bar)
        self._dial = QDial()
        layout.addWidget(self._dial)
        self.setLayout(layout)

Every subclass must call the parent constructor with the right arguments in the right order. Every user, every widget, every time. Forget it and you get a half-initialized widget that fails in confusing ways later. That's O(N).

Here's the same idea in IPUI:

class PowerBar(_BaseWidget):
    def build(self):
        self.color_bg = Style.COLOR_CARD_BG
        MgrColor.apply_bevel(self, "sunken")

No super().__init__(). No *args, **kwargs forwarding. No parent parameter to thread through. The framework handles all of that before build() is called — self.parent, self.form, self.children, and every attribute are already wired. You literally cannot forget to pass the framework's parameters because you never see them.

If you try to override __init__, you get a clear error at class definition time. Not at runtime. Not in production.

Construction IS Attachment

card = CardCol(parent)
Title(card, "Settings")
Body(card, "Change stuff")

No add(). No pack(). No grid(). When you construct a widget inside a container, it's attached. Period.

This eliminates the entire category of "widget exists but isn't on screen" bugs — O(1). In frameworks where attachment is a separate step, every user must remember to attach every widget every time — O(N).

from ipui import *

IPUI uses a star import deliberately. Conventional wisdom says they're dangerous because you don't know what you're importing.

IPUI controls __all__ explicitly. You get exactly the public API — the widgets, the base classes, the style constants — and nothing else. One import line, and you're building. No ceremony, no six-line import blocks to maintain, no "which subpackage was PowerGrid in again?"

The alternative is asking every user to maintain their import list for every file — O(N).

No "Private" Underscores

IPUI uses underscores for structural meaning, never as 'suggested scope modifiers'. _BaseWidget means "structural base class." private_enabled means "backing storage." A leading underscore never means "don't touch this."

One Class Per File (There are a couple of exceptions)

Every .py file contains one public class. The filename matches the class name exactly. Button.py has Button. PowerGrid.py has PowerGrid.

This makes discovery trivial — in your file browser, in your IDE, in conversation. No hunting through a 2,000-line widgets.py to find the class you need. And it's how IPUI's tab discovery system works: a tab named "Settings" automatically finds Settings.py.

All Code Lives in Classes

No loose functions at module level. No executable code outside if __name__ == "__main__":.

For one, this eliminates order dependency.

Also, module-level code runs on import, in whatever order Python resolves dependencies. That's a source of subtle, order-dependent bugs that are painful to diagnose. Wrapping everything in classes eliminates this entirely — O(1) structural protection instead of O(N) discipline from every developer on every file.

Resolution Independence — No Pixel Math

IPUI scales to the physical screen automatically. You work in semantic sizes (FONT_BODY, TOKEN_PAD) and normalized coordinates (ip.to_screen(0.5, 0.5)). The framework does the pixel math.

The alternative is every user doing coordinate math for every element, then redoing it when someone runs the app on a different monitor — O(N).

TAB_LAYOUT — Single Source of Truth

Your entire app structure is one dictionary at the top of your form:

TAB_LAYOUT = {
    "Config":  ["settings", "hyperparams"],
    "Results": ["chart",    "grid"],
}

No router. No navigation stack. No registration calls. The dictionary is the structure, and IPUI builds everything from it — tab buttons, pane slots, file discovery, hot-reload.

Pygame as Foundation

"Why not tkinter? Why not Qt? Why not web?"

Because IPUI was built for applications that think every frame — neural network experimentation, simulations, games. The pygame loop gives you ip_think, ip_draw, and ip_draw_hud as first-class hooks. You get 60fps rendering, real-time input, and a game-loop architecture that widget-tree frameworks can't offer without bolting on a separate threading model.

And thanks to the ip service portal, you never touch the raw pygame API for layout, input, or coordinate math unless you want to.

Behaviors, Not Classifications

This is the design choice that pays the biggest compound dividend.

Most UI frameworks divide the world into container widgets and leaf widgets. Containers can hold children. Leaves cannot. If you want a widget that's mostly a leaf but needs to hold one child in one situation, you're subclassing, wrapping, or fighting the type system.

IPUI doesn't classify widgets this way. There are no "container" or "leaf" types. Every widget inherits from _BaseWidget, which handles children, layout, scrolling, events, and drawing. Whether a widget actually has children is just a runtime fact, not a type-level constraint.

The analogy is security entitlement management: never grant permissions directly to a user. Instead, grant permissions to groups, and add users to groups. In IPUI: never hard-code capabilities into specific widget subclasses. Instead, put behaviors on _BaseWidget, and let widgets opt in through attributes.

Scrolling is a perfect example. There is no ScrollableContainer class. There is no ScrollView, ScrollPane, or ScrollArea. There's scroll_v=True — a parameter on any widget. The scrolling behavior lives once, on _BaseWidget, tested once, debugged once. Any widget that sets the flag gets it for free.

The effect is easy to see in the codebase. Look at CardCol — the most-used container in IPUI:

class CardCol(_BaseWidget):
    def build(self):
        if self.color_bg is None: self.color_bg = Style.COLOR_CARD_BG
        MgrColor.apply_bevel(self, "sunken")

Two lines of behavior. It's not a special container subclass. It's _BaseWidget with a background color and a bevel. That's it. And yet it can hold any number of children, scroll them, clip them, lay them out — because those are behaviors on the base, not features of a special container type.

Row is three lines. Button is about ten. Banner is two. Across 36+ widget classes, there is almost zero behavioral drift, near-perfect DRY, and total consistency — because the behaviors live in one place and widgets simply declare which ones they want.

Every time a capability gets baked into a specific widget subclass instead of the base, it's a small tax on the entire system: one more place that can drift, one more thing to test separately, one more special case to document. IPUI treats those exceptions like technical debt — always looking to eliminate them, always pushing behavior back to the base where it's solved once.

O(1).

See for Yourself

The best evidence isn't an argument — it's the code. Run the smoke test, open F12, and browse the widget tree. Check the Widget Catalog tab and see 36+ widgets built on a single, uniform foundation. Read CardCol.py and count the lines.

Then try building something. If the approach works for you, you'll feel it in the first ten minutes.


Appendix B: The Game Loop

IPUI manages the pygame loop. Each frame executes in this order:

1. Snapshot input state     ( ip.dt, ip.mouse_*, ip.key_*)
2. Process pygame events    → UI consumes what it needs
3. ip_think(ip)             → Form, active pane, plus THINK_ALWAYS panes
4. Layout pass              → Measure, flex solve, assign rects
5. Screen clear
6. ip_draw(ip)              → Form, then active pane only
7. UI render                → Widget tree draws
8. ip_draw_hud(ip)          → Form, then active pane only
9. Display flip

QuickStart Screenshot


Appendix C: Tab Switch Lifecycle

Two form-level features can run during a tab switch: tab_on_change (the gate) and ip_activated (the welcome mat). They have similar-sounding names but completely different jobs. This appendix lays out exactly what fires when, and why you'd reach for one over the other.

Workflow

When the user clicks a tab — say, switching from Home to Forge:

User clicks tab "Forge"
  │
  ▼
TabStrip.switch_tab("Forge")
  │
  ├─► allow_switch("Forge")               ◄── tab_on_change handler fires
  │     │
  │     └─► guard_tab_switch("Forge", "Home")
  │           returns False  → ABORT switch (vetoed; tab strip stays on "Home")
  │           returns True / None → continue
  │
  ├─► cache_active_content()              ◄── snapshot Home's widgets
  ├─► self.active_tab = "Forge"
  ├─► update_button_visuals()             ◄── tab strip highlight moves
  ├─► build_tabs_widget_tree("Forge")     ◄── may run ip_setup() on first visit
  │
  └─► notify_activated("Forge")           ◄── Forge.ip_activated(ip) fires

If tab_on_change returns False, the workflow stops at the gate. Nothing else happens — no caching, no ip_activated, no visual change. If it returns anything else (or doesn't return at all), the switch proceeds and ip_activated fires on the destination tab at the end.

How They Differ

tab_on_change ip_activated
Lives on _BaseForm (one handler for the whole form) _BaseTab (per-tab) — also _BaseForm for tabless / form-level activation
Fires Before the switch happens After the switch happens
Signature handler(name, current) — destination and current tab names (strings) ip_activated(self, ip) — receives the service portal
Can veto? Yes — return False to block the switch No — by the time it runs, the switch is done
Use case "Don't let user leave Home until they pick a project" "I'm now visible — refresh data, restart animations, sync from pipeline"

When to Reach for Which

Use tab_on_change when you need to prevent a tab switch. Common cases:

  • The user has unsaved changes in the current tab.
  • A required prerequisite hasn't been met (no project loaded, no model selected, etc.).
  • A confirmation dialog needs to gate the switch.
  • App-wide policy: e.g. during training, don't allow leaving the training tab.

Use ip_activated when the switch is fine to proceed and you just need to react to becoming visible:

  • Refresh data that may have changed while the tab was off-screen.
  • Restart an animation or particle effect.
  • Sync UI state from the pipeline (e.g. update a label to reflect the current project).
  • Reset positions, counters, or scroll offsets on each visit.

Both can coexist on the same form. tab_on_change runs first; if it allows the switch, ip_activated runs on the destination tab afterwards. They aren't redundant — they're sequential checkpoints in the same lifecycle.

Form-Level Activation

ip_activated also fires at the form level when IPUI.show() brings a form to the front (or IPUI.back() returns to a previous one). The framework sets up the service portal — ip.form, ip.tab, ip.tab_name, ip.is_active_tab, and the geometry rects — before calling the hook, so they're correct when your code runs. Per-frame fields (ip.dt, ip.events, ip.surface) reflect the last completed frame.


Appendix Z: Detail of Widget Layout Process

Every frame, before a single pixel is drawn, IPUI runs a four-pass pipeline that turns your declarative widget tree into concrete pixel rectangles. The whole thing is orchestrated from one method on _BaseForm so you can read it top to bottom without spelunking through nested constructors.

def sane_layout(self):
    NotNP_HardLayout(self).RunLayout()           # Pass 1
    if NotNP_HardWrap(self).RunLayout():         # Pass 2
        NotNP_HardLayout(self).RunLayout()       # Pass 3 (conditional)
    NotNP_HardHug(self).RunLayout()              # Pass 4

Three peer classes, no hidden orchestration, no engine swapping, no recursion into other passes. Each class does exactly one job.


Why four passes, not one

Layout is a chicken-and-egg problem. To know how tall a paragraph is, you need to know how wide it can be. To know how wide it can be, you need to know how much horizontal space its parent has to give. To know what its parent has to give, you need to know how much its siblings need. To know how much siblings need, you need to know how tall they are. Round and round.

The honest way out: stop trying to solve it in one shot. Lay everything out once with the information you have, fix the things that need fixing, lay it out again. Two more cleanup passes for the rest. Done in bounded time, deterministic, no infinite loops, no convergence math.


Pass 1 — NotNP_HardLayout: measure and place

A two-phase walk over the entire tree.

Measure (bottom-up). Each widget caches min_width and min_height based on its own surface, its children's mins, and its frame (pad + border + gap). Flex children clamp their min to just their frame — agreeing to be squeezable in exchange for getting fair-share growth later.

Layout (top-down). Starting at the trunk's rect, hand each container its inner area, run the flex solver against its children, and assign each child a concrete pygame.Rect. Recurse. The flex solver is iterative-greedy: lock non-flex kids at their minimum, find the worst violator (a flex child whose minimum exceeds its fair share), lock it at its minimum, redistribute, repeat until no violators remain. Whatever survives gets fair share.

After Pass 1, every widget has a rect. Most of the time, the rect is right.

The exception is text widgets that got allocated a width narrower than their single-line surface. They render their full text into a too-narrow rect, which would clip without intervention.


Pass 2 — NotNP_HardWrap: wrap overflowing text

Bottom-up walk over the tree, looking only at leaf widgets that have text, a font, a surface, and wrap=True. For each one, ask a simple question: is the surface wider than the rect it was given?

If yes, re-render the surface wrapped to the allocated width. The new surface is narrower (because we asked it to be) and taller (because the text took more lines to fit). Replace node.my_surface with the new one.

The pass returns True if any surface changed size, False otherwise.

That bool is the only output. The pass mutates surfaces; it does not touch rects, mins, flex weights, or anything else.

In a typical app, most frames return False — text widgets are usually wide enough. When the bool comes back True, the orchestrator knows Pass 1's rect math is now stale (some leaves grew taller) and re-runs it.


Pass 3 — NotNP_HardLayout again (conditional)

Same code as Pass 1. Different inputs.

The wrapped leaves now report their wrapped surface dimensions during the measure phase. Their height-mins propagate upward through their parents. Vertical layout settles around the new heights. Sibling positions shift down to make room.

In your app's structure, widths are decided top-down by parent allocations that don't depend on child surface widths, so Pass 3 typically only changes heights and vertical positions. But the algorithm doesn't know or care — it's the full layout pass running again with current data.

This pass converges in one shot. Pass 2 only fires when allocated width is less than content width; after Pass 3 the allocated widths haven't changed, so if Pass 2 ran a hypothetical fourth time it would have nothing to do. Bounded. Deterministic. No ping-pong.


Pass 4 — NotNP_HardHug: shrink hugging containers

Bottom-up walk. For any widget with hug_parent=True, ask its parent to symmetrically shrink to wrap the bounding box of its visible children, plus its own pad and border.

Two hard rules:

  • Floor: never below the parent's min_width / min_height.
  • Ceiling: never beyond the rect Pass 3 settled on. Hug only shrinks.

Children never move. The parent stays centered on its original center, both edges crawling inward equally. The rect mutates in place.

Hug runs last because it depends on every previous pass having settled. You can't hug to content size if the content's surfaces are still about to wrap.


What each pass is allowed to mutate

Pass Mutates Reads
1. Layout min_width, min_height, rect, scroll_active, scroll_offset surfaces, frame, flex weights, children
2. Wrap my_surface (text leaves only) rect, wrap, surface, frame
3. Layout same as Pass 1 same as Pass 1, with new wrapped surfaces
4. Hug rect (parents of hug children only) hug_parent, sibling rects, mins

No pass touches anything outside its column. That's how four passes coexist without stepping on each other.


What you can rely on after sane_layout finishes

Every widget in the tree has:

  • A rect that reflects its final on-screen position and size.
  • A my_surface sized correctly for its rect (including any wrapping).
  • A min_width / min_height consistent with its current surface.
  • A scroll_active flag and scroll_offset clamped to the legal range.

render() then walks the tree drawing each widget into its rect. The drawing pass reads the layout output and never writes back to it. Layout and drawing are clean halves.


Why this design holds up

  • Each pass has one job. You can read any of the four classes top to bottom and understand it without holding the others in your head.
  • The orchestration is visible. Open _BaseForm.sane_layout and the whole pipeline is right there in eight lines.
  • Bounded work per frame. Layout runs once or twice, never more. Wrap runs once. Hug runs once. No while-loops, no convergence checks.
  • Deterministic. Same widget tree in, same rects out. No frame-to-frame drift, no subtle timing dependencies.
  • Honest naming. The classes are called what they do. The orchestrator is called sane_layout because that's what it is.

IPUI — Because life's too short for layout bugs.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ipui-0.1.0.tar.gz (2.5 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ipui-0.1.0-py3-none-any.whl (2.5 MB view details)

Uploaded Python 3

File details

Details for the file ipui-0.1.0.tar.gz.

File metadata

  • Download URL: ipui-0.1.0.tar.gz
  • Upload date:
  • Size: 2.5 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for ipui-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0282ef4452fb9c259143a8cbc14de4ba8bdece3d19982f32f655da88acde8cb5
MD5 56031c4113756620e988a78046cd1707
BLAKE2b-256 b353f4179ca56f45a5adbcfc8f65cd70b1827b530d0b2a178984371efb5e303c

See more details on using hashes here.

File details

Details for the file ipui-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ipui-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 2.5 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for ipui-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 739497154b12d771a5d8b97ab36b64e8dbe564c53a4f3896a1e17e40d3c1a039
MD5 e07a539c44dc9a0b2275a039966cafca
BLAKE2b-256 25837016a4d8ec391b078d426e80fefe383588bbb1f31672e3bdc810e0b59c13

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page