Skip to main content

CTkKanban

CTkKanban is a small Kanban widget for CustomTkinter. It focuses on predictable card editing and movement instead of trying to be a database framework or a complete project-management application.

It provides:

  • a CTkKanbanBoard widget with explicit editing, search, selection, menus, handle-only dragging, loading states, and light/dark theme support;
  • a schema-driven editor for any number of typed card fields;
  • granular action permissions, layout configuration, and customizable text;
  • a Tk-free BoardModel with validation and deterministic manual ordering;
  • detached snapshots, database-row adapters, and one application-owned change boundary for persistence.

The complete single-page guide is also available in docs/index.html. This README covers the same contracts in a format suited to package and repository viewers.

Contents

Install

python -m pip install CTkKanBan

CTkKanban requires Python 3.10 or newer and installs CustomTkinter 6.0.0 or newer (below major version 7). CTkKanBan is the PyPI distribution name; ctk_kanban is the lowercase Python package name.

Quick start

Import the package with its canonical lowercase name:

import customtkinter as ctk

from ctk_kanban import CTkKanbanBoard

app = ctk.CTk()
app.geometry("1000x650")

board = CTkKanbanBoard(
    app,
    columns=[
        {"id": "todo", "title": "To do"},
        {"id": "doing", "title": "Doing"},
        {"id": "done", "title": "Done"},
    ],
    cards=[
        {
            "id": 1,
            "column": "todo",
            "title": "Try the simplified board",
            "description": "Click for details and use the handle to drag.",
            "priority": "High",
            "tags": ["demo"],
        }
    ],
    card_size="normal",  # "compact", "normal", or "large"
    on_change=lambda event: print(event["type"], event["data"]),
)
board.pack(fill="both", expand=True)
app.mainloop()

columns and cards may be any iterables of mappings. Their input order is the initial manual order. Call board.get_data() at any time to retrieve the complete, detached state.

Runnable examples

Start with the smallest example for your use case. Every program can be run directly from the repository root after python -m pip install -e ".[dev]".

Goal Example What it demonstrates
Build a normal board examples/basic_board.py Minimal data, equal-width columns, editing, dragging, search, and on_change.
Put database fields on cards examples/custom_fields.py Field, CardField, custom title keys, all generated input types, display roles, validation, formatting, and visibility.
Load and save real rows examples/sqlite_board.py In-memory SQLite, cursor loading, source-key mappings, typed columns, and persistence after edits or moves.
Own the card form examples/custom_editor.py A schema-driven custom window using get_field_data(), column choices, validation, and bulk update_card().
Fetch without freezing Tk examples/async_loading.py snapshot_from_rows(), load_async(), loading state, and success/error delivery.
Explore everything together example.py Low-level field mappings, runtime schema replacement, permissions, layout, text, theme tokens, and snapshots.

The focused examples favor copyable application patterns; the larger showcase is useful for visually exploring the full board. See examples/README.md for the same index and run command.

Interaction model

  • Click a card to select it and open the editor overlay.
  • Save explicitly with Save changes or Enter; Escape cancels.
  • Drag cards only from their upper-right drag handle.
  • Use the visible menu, or right-click a card, for move and delete actions.
  • Columns use menu actions for left/right movement instead of column dragging.

There is no inline editing, click-away autosave, whole-card dragging, floating drag preview, or window-wide drag binding. A local Tk grab makes sure a handle drag always receives its release event.

The overlay covers the right half of the board without shifting its columns. It marks changed state and disables Save until a value differs. Enter saves from ordinary controls, Ctrl+Enter saves from anywhere in the overlay, and Escape cancels. Enter inside a multiline textbox inserts a new line. Opening another editor replaces the currently open overlay.

Card and column menus are compact, text-only CustomTkinter popups rather than platform tk.Menu instances. The card-size selector and generated select fields use the same newer dropdown control, so their surfaces, hover states, disabled rows, and nested submenus follow the active CTk theme.

Search is a case-insensitive substring match across only fields marked searchable; lists contribute each item. Search changes visibility, not data. Dragging and menu up/down ordering are disabled while results are filtered, because a visible index is ambiguous relative to hidden cards. Clear the query before interactive reordering.

Styling and theme tokens

Board surfaces, controls, text, hover states, and scrollbars follow the active CustomTkinter color theme. Call ctk.set_default_color_theme(...) before creating the board to use another built-in or custom theme. Priority and tag metadata remain visible as compact colored pills. The optional theme mapping can override individual board tokens when needed. DEFAULT_THEME contains the complete supported token set and unknown keys are rejected to catch spelling mistakes.

Themes now cover colors as well as component radii, borders, spacing, control heights, scrollbar width, compact-card limits, animation timing, and font definitions:

board = CTkKanbanBoard(
    app,
    columns=columns,
    cards=cards,
    theme={
        "card_corner_radius": 16,
        "card_title_font": {"size": 15, "weight": "bold"},
        "card_description_max_chars": 220,
        "column_gap": 10,
        "editor_section_corner_radius": 14,
    },
)

Visual choices belong in theme; behavior, layout, and user-facing labels belong in config.

Call merge_theme(overrides) when a reusable, validated mapping is useful. It reads fresh defaults from the active CustomTkinter theme, deep-copies the overrides where possible, and rejects unknown token names. DEFAULT_THEME is the import-time discovery snapshot. A color may be a normal CustomTkinter color string or a (light, dark) pair. Font tokens are keyword mappings passed to ctk.CTkFont. Palette tokens such as column_accent_colors and tag_pill_colors must remain non-empty sequences.

Complete token reference

The supported token set is intentionally closed. Values are shown through DEFAULT_THEME; the table below describes all 118 names.

Area Tokens
Board surfaces board_fg_color, toolbar_fg_color, column_fg_color, column_header_fg_color, column_border_color, column_accent_colors
Cards and drag feedback card_fg_color, card_hover_color, dragging_card_fg_color, card_metadata_fg_color, card_border_color, selected_border_color, drop_indicator_color
Shared colors text_color, muted_text_color, accent_color, control_hover_color, count_fg_color, empty_icon_fg_color, divider_color, danger_color
Editor/input/scroll colors editor_fg_color, editor_section_fg_color, input_border_color, scrollbar_color, scrollbar_hover_color, error_text_color
Pills and priorities pill_text_color, priority_low_color, priority_medium_color, priority_high_color, priority_critical_color, tag_pill_colors
Custom context menu menu_fg_color, menu_text_color, menu_hover_color, menu_disabled_text_color, menu_border_color, menu_corner_radius, menu_border_width, menu_padding, menu_min_width, menu_item_height, menu_item_corner_radius, menu_separator_margin, menu_font
Board and toolbar geometry board_padding_x, board_padding_y, toolbar_height, toolbar_corner_radius, toolbar_padding_x, toolbar_padding_y, toolbar_content_padding_y, search_width, button_height, control_corner_radius, small_control_size
Toolbar fonts toolbar_title_font, toolbar_summary_font
Column geometry column_corner_radius, column_border_width, column_gap, column_accent_height, column_header_padding_x, card_gap
Column fonts column_title_font, column_count_font, column_empty_title_font, column_empty_body_font
Card geometry and limits card_corner_radius, card_border_width, card_selected_border_width, card_accent_width, card_padding_x, card_padding_y, card_content_gap, card_action_size, card_action_margin, card_description_max_chars, card_max_visible_tags, pill_height, pill_corner_radius, pill_padding_x, pill_gap, pill_row_gap
Card fonts card_title_font, card_body_font, card_metadata_font, card_action_font, pill_font
Editor layout editor_border_width, editor_header_padding_x, editor_header_padding_y, editor_form_padding_x, editor_form_padding_y, editor_field_padding_x, editor_field_gap, editor_section_gap, editor_section_corner_radius, editor_section_border_width, editor_section_title_padding_y
Editor motion editor_slide_step, editor_slide_interval_ms
Editor fonts editor_eyebrow_font, editor_title_font, editor_status_font, section_title_font, field_label_font, help_text_font, status_text_font, editor_input_font, editor_button_font
Inputs and scrollbar input_height, compact_input_height, input_corner_radius, input_border_width, textbox_height, scrollbar_width

Theme mappings are resolved at construction time. Changing the source mapping later does not restyle existing child widgets; create or rebuild the board when switching to a substantially different per-board theme.

Configuration and permissions

config accepts a BoardConfig instance or a nested mapping. It separates available actions, major layout choices, labels, and delete confirmation:

board = CTkKanbanBoard(
    app,
    columns=columns,
    cards=cards,
    config={
        "actions": {
            "delete_cards": False,
            "delete_columns": False,
            "move_columns": False,
        },
        "layout": {
            "show_toolbar": True,
            "fill_columns": True,
            "use_builtin_editor": True,
            "card_size": "compact",
            "column_width": 340,
            "column_height": 560,
            "editor_width": 480,
        },
        "text": {
            "board_title": "Release planning",
            "add_card": "+ New work item",
        },
        "confirm_delete": True,
    },
)

The same configuration can be constructed with frozen dataclasses:

from ctk_kanban import ActionConfig, BoardConfig, LayoutConfig, TextConfig

config = BoardConfig(
    actions=ActionConfig(delete_cards=False, delete_columns=False),
    layout=LayoutConfig(column_width=340, column_height=560),
    text=TextConfig(board_title="Release planning"),
    confirm_delete=True,
)

Nested mappings may be partial; omitted values retain the defaults. Unknown keys are rejected. merge_config(config) validates either representation and returns a BoardConfig.

Action settings

All action settings default to True.

Setting Affected board behavior
add_cards Add-card controls and add_card(); the add-card editor becomes a no-op.
edit_cards Card opening/edit controls and update_card(); on_card_open is not called when editing is disabled.
move_cards Dragging, card move menus, editor column changes, move_card(), and update_card() calls that change column.
delete_cards Card delete menus and delete_card(); also blocks cascading deletion through a non-empty column.
add_columns Add-column controls/dialog and add_column().
edit_columns Column rename controls and update_column().
move_columns Column left/right controls and move_column().
delete_columns Column delete controls and delete_column().

Disabled public mutation methods raise BoardModelError. UI-opening helpers return without opening when their action is disabled. These settings do not block set_data(), set_fields(), or direct access to board.model; they are widget behavior controls, not a security boundary. BoardModel deliberately has no action configuration.

When card deletion is disabled, delete_column(..., delete_cards=True) is also blocked for non-empty columns. This prevents column deletion from bypassing the card policy. confirm_delete only controls confirmation dialogs started by the built-in menus; direct API deletions never prompt.

Layout settings

Setting Default Contract
show_toolbar True Show the title, summary, search, and add buttons. search() remains usable when hidden.
enable_drag True Enable handle-only card dragging when move_cards is also enabled. Menu/API movement remains available when only dragging is off.
use_builtin_editor True Use the generated overlay. When false, on_card_open(card) can handle card clicks and add-editor opening is a no-op.
fill_columns False Give every column an equal share of surplus horizontal space. column_width remains the minimum, so narrower boards still scroll.
card_size "normal" Initial card density: "compact", "normal", or "large". The toolbar menu and set_card_size() can change it at runtime.
column_width 320 Column width in widget pixels; integer at least 220.
column_height 500 Minimum board-column height; integer at least 240.
editor_width 420 Compatibility setting for the editor's fixed-width fallback; the built-in board editor occupies exactly the right half of the board. Integer at least 320.

Scrollbars use a 14-pixel default thickness and can be resized with the scrollbar_width theme token. The horizontal range is recalculated after host window resizes, so columns remain reachable on a narrower window. A wheel gesture scrolls a column vertically when it has overflow; over a short column or the board background it moves horizontally. Shift+wheel always moves the board horizontally.

Text settings

Setting Default
board_title "Board"
search_placeholder "Search cards…"
add_card "+ Add card"
add_column "Add column"
no_columns "No columns yet"
no_columns_help "Create a column to start planning"
no_cards "No cards yet"
no_cards_help "Add a card to get started"
no_results "No results"
no_results_help "Try another search"

Every text setting must be a string. These are the stable application-facing labels; short mechanical labels inside the editor and context menus are not currently configurable.

For the common deletion-only case, the convenience arguments are equivalent:

board = CTkKanbanBoard(
    app,
    columns=columns,
    cards=cards,
    allow_card_deletion=False,
    allow_column_deletion=False,
)

Existing direct options such as show_toolbar, enable_drag, use_builtin_editor, fill_columns, column_width, column_height, card_size, editor_width, confirm_delete, and board_title override the corresponding structured setting when explicitly supplied. The two allow_*_deletion arguments override actions.delete_cards and actions.delete_columns. Remaining keyword arguments are forwarded to the outer CTkFrame after the board supplies its default fg_color.

Configurable card fields

Pass fields to define any number of typed card values. The built-in editor is generated from these definitions, card rendering uses their display roles, and search uses fields marked searchable. When fields is omitted, the existing title, description, priority, and tags behavior remains unchanged.

Concise fields

Use Field for a fluent definition similar to CTkDataTable's Column builder. A plain string is shorthand for a visible, editable, searchable text field; its database-style key is converted to a readable label automatically.

from ctk_kanban import CardField, Field

fields = [
    Field("summary").label("Task").title(),
    "customer_name",  # Customer Name text input and metadata pill
    Field("description").textarea().body(),
    Field("severity")
        .select(["Low", "Medium", "High"])
        .badge(colors={"High": "#EF4444"}),
    CardField("due_date", label="Due", type="date"),
    Field("estimate_hours").label("Estimate").integer(minimum=0),
    Field("internal_notes").textarea().editor_only(),
]

CardField is the immutable typed form. Field(...).build() returns a detached CardField. Field provides text(), textarea(), number(), integer(), select(), multiselect(), date(), datetime(), checkbox(), and tags() input helpers. Its display and behavior helpers are title(), body(), badge(), metadata(), card_only(), editor_only(), hide(), read_only(), required(), searchable(), section(), placeholder(), help(), default(), length(), validate(), and fmt().

Exactly one field is the title. Calling .title() allows a database key such as summary to be the heading. If no supplied field has that role, the normal title definition is inserted automatically.

Advanced mapping definitions

Existing mapping definitions remain supported for callers that want to set each low-level schema option explicitly:

fields = [
    {
        "key": "title",
        "label": "Title",
        "type": "text",
        "required": True,
        "show_on_card": True,
        "searchable": True,
        "card_role": "title",
        "section": "Details",
    },
    {
        "key": "client",
        "label": "Client",
        "type": "text",
        "show_on_card": True,
        "searchable": True,
        "card_role": "metadata",
        "section": "Details",
    },
    {
        "key": "estimate",
        "label": "Estimate",
        "type": "integer",
        "min": 0,
        "max": 100,
        "show_on_card": True,
        "card_role": "metadata",
        "section": "Planning",
    },
    {
        "key": "blocked",
        "label": "Blocked",
        "type": "checkbox",
        "section": "Planning",
    },
    {
        "key": "stage",
        "label": "Stage",
        "type": "select",
        "options": ["Discovery", "Delivery", "Review"],
        "show_on_card": True,
        "card_role": "badge",
        "colors": {
            "Discovery": ("#DBEAFE", "#1E3A5F"),
            "Delivery": ("#DCFCE7", "#14532D"),
        },
    },
    {
        "key": "due_date",
        "label": "Due date",
        "type": "date",
        "show_on_card": True,
        "card_role": "metadata",
    },
]

cards = [
    {
        "id": 1,
        "column": "todo",
        "title": "Prepare proposal",
        "client": "Acme",
        "estimate": 8,
        "blocked": False,
        "stage": "Delivery",
        "due_date": "2026-08-28",
    }
]

board = CTkKanbanBoard(app, columns=columns, cards=cards, fields=fields)

Field types and stored values

Type Generated editor control Normalized stored value
text Single-line entry Trimmed str; None becomes "".
textarea Multi-line textbox Trimmed str; None becomes "".
number Entry float, or None when blank. Booleans are rejected.
integer Entry int, or None when blank; a fractional float is rejected.
select Custom dropdown The selected value. Non-empty options are enforced.
multiselect Add/remove pill input Deduplicated list; non-empty options are enforced on save.
date Entry ISO YYYY-MM-DD string or ""; accepts datetime.date input.
datetime Entry ISO date-time string or ""; accepts datetime.datetime and Z input.
checkbox Checkbox Strict bool.
tags Add/remove tag pills Deduplicated list[str]; values are trimmed, nonblank, and comma-free.
hidden Hidden by default Deep-copied application value; useful for schema-controlled values without UI.

Date and date-time controls are plain text entries, so validation occurs on save. multiselect uses the same compact add/remove interaction as tags; invalid choices are reported when the schema validates the completed card.

Field definition reference

Every low-level mapping definition requires a unique, nonblank key and label; Field, CardField, and string shorthand infer the label.

Option Default Meaning
type "text" One of the eleven types above.
required False Reject None, "", and []. The title is always required.
default Type default Deep-copied when the field is absent. Lists default to [], checkbox to False, numeric fields to None, and other controls to "" in a new editor.
placeholder "" Entry hint; list fields fall back to "Add a value".
options () Sequence of allowed select or multiselect values. An empty sequence means unrestricted validation.
show_on_card Role-dependent Render the value on the compact card. Defaults to true unless the role is hidden.
show_in_editor Type-dependent Generate an editor control. Defaults to false only for hidden.
searchable False Include the field in local case-insensitive substring search.
read_only False Disable its generated control while retaining the value. Give required read-only fields a default for new cards.
section "Details" Drawer section heading. Sections follow first appearance; column selection is in Organisation.
card_role metadata or hidden Compact-card presentation role. A visible field defaults to metadata; otherwise hidden.
help_text "" Supporting editor text; for checkboxes it becomes the checkbox label.
min, max unset Inclusive numeric limits for number and integer.
min_length, max_length unset Inclusive character/item count limits for strings and lists.
validator unset Callable `(value, card) -> bool
formatter unset Callable (value, card) -> str used only for compact-card display.
colors {} Mapping from exact field values to CustomTkinter-compatible pill/accent colors.

Unknown definition options are rejected so spelling mistakes cannot silently change behavior. min must not exceed max, length limits must be nonnegative, and the lower length limit must not exceed the upper one. Validators run in field order and should be fast and side-effect free because they also run during load and schema replacement.

def estimate_validator(value, card):
    if value is not None and card.get("blocked") and value > 8:
        return "Blocked work must be split into estimates of 8 or less"
    return True


fields = [
    {"key": "title", "label": "Title", "required": True},
    {"key": "blocked", "label": "Blocked", "type": "checkbox"},
    {
        "key": "estimate",
        "label": "Estimate",
        "type": "integer",
        "validator": estimate_validator,
        "formatter": lambda value, _card: "" if value is None else f"{value} pts",
        "show_on_card": True,
        "card_role": "metadata",
    },
]

Compact-card roles

Role Presentation
title Main heading. Exactly one text/textarea field has this role; its key may match the source database column.
body Wrapped body line, truncated by card_description_max_chars. Multiple body fields are supported.
badge Colored pill; the first non-empty visible badge also colors the card's accent strip.
tags One #value pill per item, capped per field by card_max_visible_tags.
metadata Pill formatted as Label: value.
hidden No compact representation.

Only fields with show_on_card=True render, regardless of role. Empty values are omitted. formatter changes display text but never the normalized stored value. A field's colors mapping takes precedence for its exact value; the default priority field otherwise uses the priority theme colors.

id, column, and column_id are reserved structural keys. If no supplied field uses the title role, the default title definition is inserted. The chosen heading must use text or textarea, is forced to required=True and show_on_card=True, and must be the only field with card_role="title".

Additional card keys that are not in the schema are deep-copied and preserved in snapshots. This lets applications round-trip private integration metadata without showing, validating, formatting, or searching it. Add a definition whenever the board should understand such a value.

Use get_fields() to inspect the active definitions and set_fields(fields) to replace them at runtime. set_fields() validates existing cards atomically, rebuilds their compact views, and rebuilds an open editor with the new controls. If schema normalization changes stored card values, one fields_changed event is emitted so the host can persist the new snapshot.

get_fields() returns a detached copy. Mutating that copy has no effect until it is passed back to set_fields(). A failed replacement leaves both the old schema and every card unchanged. Changing a schema can add defaults, coerce values, or reject existing data; plan schema migrations the same way you would plan a database migration.

Data contract and ordering

A snapshot is exactly one mapping with columns and cards keys:

snapshot = {
    "columns": [
        {"id": "todo", "title": "To do"},
        {"id": "done", "title": "Done"},
    ],
    "cards": [
        {"id": 1, "column": "todo", "title": "Write docs"},
    ],
}

Extra top-level snapshot keys and extra column keys are rejected. A column has only id and title; titles are trimmed and must remain nonblank. A card must have id, a column reference, and a nonblank title. Input may use column_id as an alias for column; output always uses column. Supplying both aliases with different values is an error.

IDs must be unique within their record kind and must be a nonblank str or an int; booleans, floats, None, and other objects are rejected. IDs are not coerced, so 1 and "1" are distinct. Use only strings or integers when serializing to JSON and keep the same type in related card/column values.

With the default field schema, cards normalize title, description, priority, and tags. Priority is case-sensitive and must be empty, Low, Medium, High, or Critical. A custom schema controls its configured keys; all other card keys are preserved as application metadata.

Snapshots, copies, and atomic replacement

get_data() and BoardModel.snapshot() return deep, detached state. The record getters and returned mutation records are detached too, so changing them never mutates the live board. Call a mutation method or set_data() to apply a change.

set_data(snapshot) validates the entire replacement before changing the model and redraws without emitting on_change. Invalid replacement data leaves the old board intact. The snapshot shape must be complete; use empty lists to clear a board. BoardModel.load() additionally supports the explicit form load(columns=..., cards=...), but snapshot and explicit arguments cannot be mixed.

Manual ordering

Column input order is retained. Card order is retained within each column and global get_cards() output is grouped by current column order. index values are zero-based insertion positions; None appends. Valid insertion positions range from zero through the destination size, inclusive. An out-of-range or non-integer index raises BoardModelError.

update_card() merges its mapping into the existing record; it does not replace unspecified values. A changed column appends the card to that column, while move_card() accepts an exact insertion index. update_column() only accepts title. Non-empty columns require the explicit delete_cards=True cascade flag before deletion.

Change events and persistence

Pass one on_change(event) callback to observe successful board mutations. Every event contains a detached before snapshot, the current complete data snapshot, its type, and the operation payload:

Event type Additional payload
card_added card
card_updated card, previous
card_deleted card (the removed record)
card_moved card, previous
column_added column
column_updated column, previous
column_deleted column; any cascaded cards remain available in before
column_moved column
fields_changed fields (the new definitions), emitted only if schema normalization changed stored cards

No event is emitted for a no-op update or move, set_data(), a schema change that leaves card data identical, search, selection, rendering, or loading-state changes. The board state has already changed when the callback runs. Callback exceptions are logged and do not roll back the mutation.

def persist(event):
    try:
        repository.save_snapshot(event["data"])
    except Exception:
        # Optional application policy: restore the last detached state.
        board.set_data(event["before"])
        raise


board = CTkKanbanBoard(app, columns=columns, cards=cards, on_change=persist)

on_change runs on Tk's UI thread, so synchronous network or database work will freeze the interface. For nontrivial persistence, enqueue event["data"] to an application worker and decide how your application handles failures, retries, coalescing, optimistic concurrency, and conflicts. CTkKanban does not save, poll, page, retry, or resolve conflicts itself.

Database rows

Database results can be displayed without renaming every selected column. CTkKanbanBoard.from_rows() accepts mapping rows, sqlite3.Row, SQLAlchemy mapping rows, or executed DB-API cursors directly. Key mappings point from the board's structural names to the source query names:

from ctk_kanban import CTkKanbanBoard, Field

board = CTkKanbanBoard.from_rows(
    app,
    columns=column_cursor,
    cards=card_cursor,
    column_keys={"id": "status_id", "title": "status_name"},
    card_keys={
        "id": "task_id",
        "column": "status_id",
        "title": "summary",
    },
    fields=[
        "customer_name",
        Field("severity").badge(),
        Field("due_date").date(),
        Field("estimate_hours").integer(),
    ],
)
board.pack(fill="both", expand=True)

Mapped structural source keys are consumed and outputs use canonical id, column, and title. Other selected columns retain their database names. Missing mapped columns and unknown mapping targets produce clear errors.

Plain DB-API tuple results can be converted using cursor metadata. Fetch each result before reusing its cursor:

from ctk_kanban import rows_from_cursor, snapshot_from_rows

cursor.execute("SELECT id, title FROM kanban_columns ORDER BY position")
columns = rows_from_cursor(cursor)

cursor.execute(
    """
    SELECT id, column_id AS column, title, description, priority, tags
    FROM kanban_cards
    ORDER BY column_id, position
    """
)
cards = rows_from_cursor(cursor)

board.set_data(snapshot_from_rows(columns, cards))

SQL aliases such as column_id AS column remain a valid alternative to key mappings. Result column names must be unique. rows_from_cursor() consumes all remaining rows returned by the cursor.

snapshot_from_cursors(columns_cursor, cards_cursor) is a shorter equivalent when two separately executed cursors are available. Every snapshot helper normalizes and validates the complete result before returning it.

Adapter Behavior
normalize_row(row) Copies a mapping, an object with _mapping, or a keys/index row to dict[str, Any]. Plain tuples have no names and raise TypeError.
normalize_rows(rows) Applies normalize_row() to an iterable.
rows_from_cursor(cursor) Requires an executed result with description, verifies unique column names, calls fetchall(), and zips each tuple to those names.
snapshot_from_rows(columns, cards, *, fields=None, card_keys=None, column_keys=None) Accepts row iterables or cursors, applies optional source-key mappings, validates through a temporary BoardModel, and returns its detached snapshot.
snapshot_from_cursors(columns_cursor, cards_cursor, *, fields=None, card_keys=None, column_keys=None) Consumes two separately executed cursors and delegates to snapshot_from_rows().

Pass the same fields used by the board so database values receive identical normalization. SQL drivers may return JSON/array values in driver-specific forms; convert them to the expected Python list, boolean, number, or string in the query/repository layer when necessary. Neither adapter executes SQL, commits, closes a cursor, nor owns a connection.

Custom editor forms

Set use_builtin_editor=False and pass on_card_open when the application should own the editor window. The callback receives a complete detached card record. Use its id to request the form-ready field data for that card:

def card_clicked(card):
    field_data = board.get_field_data(card["id"])
    open_my_form(card, field_data)


board = CTkKanbanBoard(
    app,
    columns=columns,
    cards=cards,
    fields=fields,
    use_builtin_editor=False,
    on_card_open=card_clicked,
)

Providing on_card_open also replaces the edit overlay when use_builtin_editor=True, preserving the original callback behavior. The callback and every Tk widget it creates run on Tk's main thread. Callback exceptions are logged under ctk_kanban so the originating click can finish.

Getting editor-ready field data

get_field_data(card_id) returns dict[str, CardFieldData]. The outer mapping follows schema order and is keyed by each field's stable key; each CardFieldData contains the normalized field definition plus that card's detached value:

fields = board.get_field_data(card_id=17)
status = fields["status"]

print(status["label"])       # Status
print(status["type"])        # select
print(status["value"])       # Doing
print(status["options"])     # ("To do", "Doing", "Done")
print(status["read_only"])   # False

The nested mappings contain the same options documented in the field definition reference, including label, type, required, default when configured, placeholder, options, show_on_card, show_in_editor, searchable, read_only, section, card_role, help_text, limits, validators, formatters, and colors. value is the stored normalized value. If an optional value is absent, it is the same editor default used by the built-in form: [] for list controls, False for checkboxes, None for numbers, and "" for other controls unless the schema supplies default.

The result is detached. Changing a returned label, option collection, or value does not change the schema or card. Call set_fields() to replace definitions and use a mutation method to save values. Only configured fields are returned; structural id/column values and unconfigured private metadata remain available from get_card(). Unlike the forgiving widget get_card() helper, get_field_data() raises BoardModelError for an invalid or unknown card ID.

Targeting controls

Always index controls by field["key"], never by their label. Labels and section headings are presentation text and need not be unique. A minimal schema-driven form loop looks like this:

controls = {}
field_data = board.get_field_data(card["id"])

for key, field in field_data.items():
    if not field["show_in_editor"]:
        continue
    controls[key] = create_control(
        label=field["label"],
        field_type=field["type"],
        value=field["value"],
        options=field.get("options", ()),
        disabled=field["read_only"],
    )

Custom controls should return the stored type expected by the schema:

Field type Form should submit
text, textarea, date, datetime A string; date values use ISO formats.
number, integer A number, numeric entry string, or blank value.
select The original selected option value, not merely its display label.
multiselect A list containing original option values.
checkbox A real bool.
tags A list of nonblank strings without commas.
hidden Normally no control; preserve or update it through application logic.

Column is structural rather than a configured field. Read it from card["column"], build choices from board.get_columns(), and submit the chosen column ID as "column" only when board.actions.move_cards is true. Use column IDs as values because column titles can be duplicated.

Saving one field or the whole form

Use update_field(card_id, field_key, value) for one targeted change:

try:
    updated_status = board.update_field(17, "status", "Done")
except BoardModelError as error:
    show_error(str(error))
else:
    print(updated_status["value"])  # "Done"

The return value is the refreshed CardFieldData, so numeric strings and other inputs can immediately be inspected after normalization. The method requires a configured field key and intentionally rejects id, column, and unconfigured private keys. It delegates to update_card(), which means it uses the same validation, edit permission, card redraw, and card_updated event behavior. A no-op produces no event. read_only and show_in_editor control form presentation; application code may still update those fields programmatically.

When a Save button commits multiple controls, collect one mapping and call update_card() once. This validates the completed card, redraws once, and emits at most one event:

updates = {
    key: control.get_value()
    for key, control in controls.items()
    if not field_data[key]["read_only"]
}

try:
    board.update_card(card["id"], updates)
except BoardModelError as error:
    show_form_error(str(error))
else:
    close_form()

Keep the form open after BoardModelError so the user can correct required, type, option, limit, or custom-validator failures. Persistence continues to flow through the normal on_change callback after a successful update. The complete runnable examples/custom_editor.py builds CustomTkinter controls from this contract, includes column selection and read-only handling, and reports validation errors without closing.

Asynchronous loading

load_async() performs fetching and validation on a daemon worker, then calls set_data() and user callbacks safely on Tk's thread:

import psycopg
from psycopg.rows import dict_row

from ctk_kanban import snapshot_from_rows


def fetch_board():
    with psycopg.connect(DATABASE_URL, row_factory=dict_row) as connection:
        columns = connection.execute(COLUMN_QUERY).fetchall()
        cards = connection.execute(CARD_QUERY).fetchall()
        return snapshot_from_rows(columns, cards)


board.load_async(
    fetch_board,
    on_success=lambda snapshot: print("Loaded", len(snapshot["cards"]), "cards"),
    on_error=lambda error: print("Load failed:", error),
)

board.is_loading reports pending work and board.load_error retains the most recent asynchronous error. Existing data is preserved on failure unless clear_on_error=True is requested. Starting a newer load makes an older result stale, so it cannot overwrite newer data.

load_async() returns the started daemon threading.Thread. The fetch callable receives no arguments and must return a complete snapshot mapping. It runs off-thread and must not read or update Tk widgets. Validation uses the board field schema; application of the validated snapshot plus on_success or on_error happens on Tk's event loop. Successful loads use set_data() and therefore do not emit on_change.

At the start of a load, load_error is cleared and is_loading becomes true. On failure, the original exception is stored and passed to on_error; with clear_on_error=True, the board is replaced with empty lists first. A newer load invalidates delivery from older workers but does not forcibly stop their Python threads. Destroying the board invalidates pending delivery, and starting a load after destruction raises RuntimeError.

set_loading(True) is also public for application-owned tasks. It changes the toolbar presentation and disables its search/add controls; it does not start a worker, block public mutations, or alter data.

Card schemas, fonts, and search text are reused across compact cards. Static text uses lightweight, appearance-aware Tk primitives, while the accent strip, action buttons, and pills use anti-aliased native CustomTkinter widgets. Pill labels come from a reusable pool. Search hides and reorders existing cards instead of recreating them. Column mutations retain their existing card and scroll widgets, and all board scroll frames share one routed wheel binding. Initial rendering and set_data() still scale with the number of cards, but avoid compound CustomTkinter widgets for every static label.

API reference

CTkKanbanBoard constructor

CTkKanbanBoard(
    master,
    columns=(),
    cards=(),
    *,
    on_change=None,
    on_card_open=None,
    theme=None,
    fields=None,
    config=None,
    show_toolbar=None,
    enable_drag=None,
    use_builtin_editor=None,
    fill_columns=None,
    card_size=None,
    column_width=None,
    column_height=None,
    editor_width=None,
    confirm_delete=None,
    allow_card_deletion=None,
    allow_column_deletion=None,
    board_title=None,
    **kwargs,
)

master is the parent Tk widget. columns and cards provide initial state; all other inputs are keyword-only. theme, fields, and config use the contracts above. on_change receives event dictionaries and on_card_open receives a detached card record when a card is clicked. Direct layout/text/deletion options take precedence over structured config.

CTkKanbanBoard.from_rows()

CTkKanbanBoard.from_rows(
    master,
    columns,
    cards,
    *,
    card_keys=None,
    column_keys=None,
    fields=None,
    **board_options,
)

This classmethod consumes supported row iterables or executed DB-API cursors, maps database names, validates the complete snapshot, and returns a normal CTkKanbanBoard. card_keys accepts canonical id, column, and title targets; column_keys accepts id and title.

Board data, view, and schema methods

Member Return Behavior
get_data() BoardSnapshot Detached complete state.
set_data(data) None Atomically validate, replace, and redraw without an event.
get_card(card_id) `CardRecord None`
get_cards(column_id=None) list[CardRecord] Ordered detached records; an unknown explicit column raises.
get_columns() list[ColumnRecord] Ordered detached records.
get_fields() list[dict[str, Any]] Detached normalized definitions.
get_field_data(card_id) dict[str, CardFieldData] Ordered definitions plus detached editor-ready values for one known card.
set_fields(fields) None Atomic schema replacement, redraw, and optional fields_changed event.
get_selected_card() `CardRecord None`
search(query) None Set case-insensitive local search; non-string values are converted with str().
refresh(preserve_scroll=True) None Rebuild structural widgets from current model state. Ordinary public mutations already refresh what they need.
set_card_size(size) None Apply "compact", "normal", or "large" card geometry and typography, preserving the choice through later refreshes.

Board mutation methods

Member Return Notes
add_card(card, *, index=None) CardRecord Add to card["column"] at an insertion position.
update_card(card_id, updates) CardRecord Merge fields. Changing column additionally requires move permission.
update_field(card_id, field_key, value) CardFieldData Validate and update one configured value, returning its refreshed definition and normalized value.
move_card(card_id, column_id, index=None) CardRecord Move/reorder, appending when index is omitted.
delete_card(card_id) CardRecord Remove and return the old record; no confirmation prompt.
add_column(column, *, index=None) ColumnRecord Add at an insertion position.
update_column(column_id, updates) ColumnRecord Rename from {"title": ...}.
move_column(column_id, index) ColumnRecord Reorder to an exact position.
delete_column(column_id, *, delete_cards=False) ColumnRecord Empty-only unless cascade is explicit; no confirmation prompt.

Each board mutation enforces its action setting and emits the corresponding event only when data changes.

Board editor and loading methods

Member Return Behavior
open_add_card_editor(column_id=None) None Open the generated overlay. It is a no-op when use_builtin_editor=False; otherwise, if no column exists and adding columns is allowed, prompt for one first.
open_edit_card_editor(card_id) None Open the overlay or invoke the configured custom callback; invalid/unknown IDs are ignored.
open_add_column_dialog() None Prompt for a title and create a UUID-backed column.
is_loading bool Read-only property describing pending async delivery/manual presentation.
load_error `Exception None`
set_loading(loading) None Toggle presentation; requires a real boolean.
load_async(fetch_snapshot, *, on_success=None, on_error=None, clear_on_error=False) threading.Thread Start validated background loading.
destroy() None Cancel scheduled delivery and editor motion, release menus/grabs/scroll bindings, and tear down widgets. Safe to call more than once.

BoardModel

The Tk-free BoardModel(columns=(), cards=(), *, fields=None) is useful in repositories, tests, command-line tools, and preprocessing layers. It supplies:

snapshot()
load(data=None, *, columns=None, cards=None)
get_card(card_id) / get_cards(column_id=None) / get_columns()
get_fields() / set_fields(fields)
add_card(card, *, index=None)
update_card(card_id, updates=None, **changes)
delete_card(card_id)
move_card(card_id, column_id, index=None)
reorder_card(card_id, index)
add_column(column, *, index=None)
update_column(column_id, updates=None, **changes)
delete_column(column_id, *, delete_cards=False)
move_column(column_id, index)
clear()

Unlike the widget's forgiving get_card(), BoardModel.get_card() raises BoardModelError for an invalid or unknown ID. The model does not enforce BoardConfig permissions or emit events. Its update_card() and update_column() accept keyword changes in addition to an optional mapping; model update_column() also accepts a title string directly.

Public records and helpers

Name Kind and purpose
Column Frozen dataclass with id and title; from_definition() validates and trims a mapping.
Card Frozen compatibility dataclass for the four default fields; tags are a tuple. It is not a container for arbitrary schema fields.
ColumnRecord TypedDict output with id and title.
CardRecord dict[str, Any] because schema and private values are dynamic.
CardFieldData TypedDict extending FieldDefinition with the detached card value.
BoardSnapshot TypedDict with columns and cards lists.
CardField Frozen, typed convenience definition with readable visible/editor/search defaults.
Field Fluent builder for card input type, compact display, validation, formatting, and visibility.
FieldDefinition TypedDict describing a generated field.
FieldInput Accepted union of str, CardField, Field, and legacy field mappings.
FieldType Literal union of supported field type strings.
DEFAULT_FIELDS Tuple containing detached-compatible definitions for title, description, priority, and tags.
ActionConfig, LayoutConfig, TextConfig, BoardConfig Frozen configuration dataclasses.
DEFAULT_THEME Import-time dictionary of all theme defaults/tokens.
merge_config(), merge_theme() Strict validation/merge helpers.
normalize_row(), normalize_rows(), rows_from_cursor() Row-to-dictionary adapters.
snapshot_from_rows(), snapshot_from_cursors() Schema-aware row/cursor adapters with optional card_keys and column_keys mappings.
__version__ Installed CTkKanban version string.

Card.from_definition() intentionally uses the default schema and returns the fixed dataclass. Use a BoardModel(fields=...) when custom keys must remain in the result.

Errors and lifecycle

BoardModelError subclasses ValueError and is raised for invalid records, schemas, IDs, indices, relationships, updates, protected deletions, and board actions disabled by configuration. Configuration helpers also use TypeError for incorrect value kinds and ValueError for invalid names/ranges. Theme helpers reject unknown token names with ValueError; CustomTkinter may report invalid token values later while constructing a widget.

Editor validation errors are shown in the overlay and keep it open. Public method errors are not swallowed. on_change and on_card_open callback exceptions are logged so the Tk interaction can finish; async success/error callbacks are application code and should handle their own failures.

All widget construction, mutations, and direct UI access should occur on the Tk thread. load_async() is the provided exception: only its fetch callable runs in a daemon worker, and that callable must stay independent of Tk. Call destroy() during normal widget teardown; it invalidates outstanding async delivery and cleans up window-level bindings and local drag state.

Migrating from 1.x

Version 2 is intentionally breaking. Replace mutation-specific callbacks with on_change, and import from ctk_kanban rather than CTkKanBan. The focused 2.0 API initially removed dynamic fields; the current schema API restores that capability without restoring the former persistence, filtering, sorting, and large constructor-flag frameworks. Custom record keys are now preserved.

1.x concept Current replacement
from CTkKanBan import ... from ctk_kanban import ...
Dynamic/generated fields The smaller fields schema documented above.
Inline and popup editing modes One embedded explicit-save overlay, or on_card_open.
Mutation-specific callbacks One on_change(event) callback with before/current snapshots.
Built-in persistence/data sources Application repositories plus snapshots, row adapters, and load_async().
Advanced filters/sorts Schema-aware local search and manual order; transform source data in the host.
Many constructor booleans BoardConfig plus a small compatibility override set.

Migrate stored records by aliasing each card's owning value to column, keep IDs stable and type-consistent, and add field definitions for values that need UI, validation, search, or compact display. Leave integration-only keys undefined in the schema if they only need to round-trip.

Development

python -m pip install -e ".[dev]"
python -m pytest -q
python -m ruff check .
python -m mypy ctk_kanban
python -m build

Release maintainers should also read docs/publishing.md. The changelog records user-visible behavior in CHANGELOG.md.

Download files

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

Source Distribution

ctkkanban-2.3.0.tar.gz (175.5 kB view details)

Uploaded Source

Built Distribution

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

ctkkanban-2.3.0-py3-none-any.whl (82.7 kB view details)

Uploaded Python 3

File details

Details for the file ctkkanban-2.3.0.tar.gz.

File metadata

  • Download URL: ctkkanban-2.3.0.tar.gz
  • Upload date:
  • Size: 175.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctkkanban-2.3.0.tar.gz
Algorithm Hash digest
SHA256 e9580c89de6725ab4b8d761aac38e454e16f2256832923eaa2b7228759d867bd
MD5 34e006a1ff44b2a270bc53169a09f9b7
BLAKE2b-256 1b21fdb5218cab060be45d51b8ed0f93b61ead06750f7a0873ed43b21321327c

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctkkanban-2.3.0.tar.gz:

Publisher: publish.yml on Harry-g25/CTkKanBan

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ctkkanban-2.3.0-py3-none-any.whl.

File metadata

  • Download URL: ctkkanban-2.3.0-py3-none-any.whl
  • Upload date:
  • Size: 82.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctkkanban-2.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b88be22a2eb75b9bb7cded7c7506f037024f5298e010ee63f93b50cd7641ecaa
MD5 742a5e38687791f12d5829691a5ef157
BLAKE2b-256 038295943a1063856435e9a4545028e11e36b1fc920a23ab726d43fde21a05fa

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctkkanban-2.3.0-py3-none-any.whl:

Publisher: publish.yml on Harry-g25/CTkKanBan

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

2.3.0 This release

2 files

2.2.1

2 files

2.1.0

2 files

2.0.1

2 files

1.0.0

2 files

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