CodeEditor#

A full-featured code editor with syntax highlighting, line numbers, bracket matching, smart indent, and built-in search/replace.

CodeEditor — light theme CodeEditor — dark theme

Usage#

Think of it as a standalone editing surface, not a form input: it ships the editor behaviors (a gutter, a search bar, undo grouping) built in, and it is not Field-wrapped — there is no label or disabled state. Reach for TextArea when you want a labeled multi-line field in a form.

Basic usage#

bs.CodeEditor()

Syntax highlighting#

Pass language= to enable Pygments syntax highlighting. Any Pygments lexer name is accepted.

bs.CodeEditor(value=python_source, language="python")
bs.CodeEditor(value=sql_source,    language="sql")
# Any Pygments lexer name works — "javascript", "html", "yaml", …
CodeEditor languages — light theme CodeEditor languages — dark theme

Color themes#

By default (theme='auto'), the editor switches between light_theme and dark_theme to match the active bootstack theme. Pass an explicit Pygments style name to pin the scheme.

bs.CodeEditor(language="python", theme="auto")           # follows app theme
bs.CodeEditor(language="python", theme="monokai")        # always dark
bs.CodeEditor(language="python", theme="default")        # always light
bs.CodeEditor(language="python",
              light_theme="friendly",
              dark_theme="dracula")                      # custom pair

Read-only state#

bs.CodeEditor(value=code, language="python")                   # editable
bs.CodeEditor(value=code, language="python", read_only=True)   # read-only

Read-only blocks typing, not programmatic edits: editor.value = ..., editor.insert(...), and editor.clear() all still apply, leaving the editor read-only afterward.

CodeEditor states — light theme CodeEditor states — dark theme

Editor options#

bs.CodeEditor(
    language="python",
    tab_width=4,           # spaces per tab stop (default 4)
    insert_spaces=True,    # Tab inserts spaces (default True)
    auto_indent=True,      # match indentation on Return (default True)
    show_line_numbers=True,  # line-number gutter (default True)
    show_indent_guides=False,  # vertical guide marks (default False)
    wrap=False,            # horizontal scroll (default; True to wrap)
)

Handling changes#

on_change() fires on every edit, delivering a ChangeEvent whose value is the editor text. Use on_input() for keystroke-level feedback before the change is committed. See Events for the full event model.

editor = bs.CodeEditor(language="python")

editor.on_change(lambda e: autosave(editor.value))

# Debounced Stream — save 500ms after the last keystroke
editor.on_change().debounce(500).listen(lambda e: autosave(editor.value))

Cursor position#

on_cursor_move() fires after any key press or mouse click that moves the insertion cursor. Read cursor_position — a 1-indexed (line, column) tuple — to drive a status-bar readout.

editor = bs.CodeEditor(language="python")

def show_pos(e):
    line, col = editor.cursor_position
    status.text = f"Ln {line}, Col {col}"

editor.on_cursor_move(show_pos)

Text positions#

Positions are 1-indexed line and col numbers — (1, 1) is the start of the content. The same coordinates flow through every position method: insert() accepts them, cursor_position reports them, and goto() moves to them. Omit col to mean the start of the line. Most edits need no position at all — insert() defaults to the cursor, and append() adds to the end.

editor.insert("snippet")               # at the cursor (the common case)
editor.append("\n# appended")          # add a trailing line
editor.insert("#!/usr/bin/env python\n", 1, 1)  # prepend at the start
editor.insert(">>> ", 3)               # at the start of line 3
editor.goto(42)                        # jump to the start of line 42
editor.goto(42, 8)                      # jump to line 42, column 8

line, col = editor.cursor_position     # e.g. (42, 8) for a status bar

For a live readout, drive a Signal from the on_cursor_move event stream. cursor_position is a point-in-time tuple, so map the stream — not the property — and read the position inside the map:

status = bs.Signal("Ln 1, Col 1")
bs.Label(textsignal=status)

(
    editor.on_cursor_move()
        .map(lambda e: "Ln {}, Col {}".format(*editor.cursor_position))
        .listen(status.set)
)

Selection and block indent#

selection reads and writes the selected range as a pair of 1-indexed (line, col) tuples — the same coordinates the position methods use — or None when nothing is selected. selected_text returns the selected string.

editor = bs.CodeEditor(value="line one\nline two\nline three")

sel = editor.selection          # e.g. ((1, 1), (2, 5)), or None
text = editor.selected_text     # the selected text, or ""

editor.selection = ((2, 1), (3, 1))  # select line 2
editor.selection = None              # clear the selection

indent() and dedent() shift whole lines by one tab stop. With a selection they act on every selected line and leave those lines selected; with no selection indent() inserts a tab stop at the cursor and dedent() outdents the current line. They are the programmatic form of pressing Tab / Shift+Tab (see Keyboard).

editor.selection = ((2, 1), (3, 1))
editor.indent()    # indent lines 2–3 by one tab stop
editor.dedent()    # and back out again

Validation#

Attach rules with add_validation_rule(); validate() runs them immediately. Validation also runs automatically on blur. Listen for results with on_valid / on_invalid, or read the reactive valid and error signals for binding.

editor = bs.CodeEditor(language="python")
editor.add_validation_rule("required", message="Content is required.")
editor.add_validation_rule("stringLength", min=10)

btn = bs.Button("Run", accent="primary")

def on_validity_change(ok):
    btn.disabled = not ok

editor.valid.subscribe(on_validity_change)
editor.on_invalid(lambda e: print("invalid:", e.message))

See Validation for the full rule set.

Undo and redo#

The editor maintains a built-in undo/redo stack. Use undo_block() to group multiple programmatic edits into a single undo step.

editor = bs.CodeEditor(language="python")
editor.undo()
editor.redo()

# Group edits into one undo step
with editor.undo_block():
    editor.insert("# Auto-generated\\n", 1, 1)
    editor.value = reformatted

Dirty tracking#

is_dirty is True after any edit since the last mark_saved() call.

editor = bs.CodeEditor(language="python")
editor.on_modified(lambda e: update_title(editor.is_dirty))

# After saving
editor.mark_saved()

Search and replace#

editor.show_search()   # open find bar
editor.show_replace()  # open find/replace bar
editor.hide_search()   # close the bar

Keyboard#

The editor uses editor-grade key bindings rather than form-field traversal. Undo and redo bind to the platform’s native shortcuts (Ctrl+Z / Ctrl+Y on Windows and Linux, Cmd+Z / Cmd+Shift+Z on macOS).

Key

Action

Tab

Indent the selected lines, or insert a tab stop at the cursor

Shift+Tab

Dedent the selected lines, or the current line

Return

New line, matching the current indent (auto_indent)

Undo / redo

Native per-platform shortcut (see above)

Ctrl+F / Cmd+F

Open the find bar

Ctrl+H / Cmd+H

Open the find/replace bar

Escape

Close the find/replace bar

Note

Tab indents, so it does not move focus out of the editor — that is deliberate for a code surface, matching desktop editors. Move focus away by clicking another control. When you instead want a labeled multi-line field where Tab advances focus, use TextArea.

Note

The Tab / Shift+Tab block indent/dedent keys are active when auto_indent=True (the default). The indent() and dedent() methods work regardless of the setting — see Selection and block indent.

Widget sizing#

All widgets accept self-placement kwargs via **kwargs. The parent container determines which options apply — Column / Row parents use the layout kwargs below, grid-based parents use grid kwargs.

Column (vertical layout)

Used inside a Column, App, or any other container with a column layout. Children are arranged top-to-bottom, so horizontal aligns each child across the width and grow shares the vertical space. (vertical does not apply — the order of the children sets their top-to-bottom position.)

horizontal

Cross-axis placement of the widget: 'left', 'center', 'right', or 'stretch' to fill the available width.

grow

Claim and fill a share of the leftover vertical space (the layout direction). True or False.

margin

External spacing in pixels. Accepts an integer (equal on all sides), a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

margin_x

Horizontal external spacing (left and right). Accepts an integer or a 2-tuple (left, right) for asymmetric spacing. Overrides the horizontal component of margin=.

margin_y

Vertical external spacing (top and bottom). Accepts an integer or a 2-tuple (top, bottom) for asymmetric spacing. Overrides the vertical component of margin=.

Row (horizontal layout)

Used inside a Row or any other container with a row layout. Children are arranged left-to-right, so vertical aligns each child across the height and grow shares the horizontal space. (horizontal does not apply — the order of the children sets their left-to-right position.)

vertical

Cross-axis placement of the widget: 'top', 'center', 'bottom', or 'stretch' to fill the available height.

grow

Claim and fill a share of the leftover horizontal space (the layout direction). True or False.

margin

External spacing in pixels. Accepts an integer (equal on all sides), a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

margin_x

Horizontal external spacing (left and right). Accepts an integer or a 2-tuple (left, right) for asymmetric spacing. Overrides the horizontal component of margin=.

margin_y

Vertical external spacing (top and bottom). Accepts an integer or a 2-tuple (top, bottom) for asymmetric spacing. Overrides the vertical component of margin=.

Grid

Used inside a Grid container.

row / column

Zero-based row and column indices.

rowspan / columnspan

Number of rows or columns to span.

horizontal

Horizontal placement within the grid cell: 'left', 'center', 'right', or 'stretch' to fill the cell width.

vertical

Vertical placement within the grid cell: 'top', 'center', 'bottom', or 'stretch' to fill the cell height.

margin

External spacing in pixels. Accepts an integer, a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

margin_x

Horizontal external spacing. Accepts an integer or (left, right).

margin_y

Vertical external spacing. Accepts an integer or (top, bottom).

See also#

  • TextArea — plain multi-line text input for form fields

API#

The complete reference for CodeEditor lives on the Widgets API page. At a glance:

CodeEditor

A full-featured code editor with line numbers, bracket matching, and syntax highlighting.

Full Example#

 1
 2SAMPLE_PY = """\
 3import bootstack as bs
 4
 5with bs.App(title="My App", padding=16, gap=12) as app:
 6    bs.Label("Hello, bootstack!", font="heading-lg")
 7    name = bs.TextField(label="Your name", placeholder="Enter name…")
 8    accent = bs.Select(["primary", "secondary", "success"], label="Accent")
 9    with bs.Row(gap=8):
10        bs.Button("Submit", accent="primary")
11        bs.Button("Cancel", variant="outline")
12app.run()
13"""
14
15SAMPLE_SQL = """\
16SELECT
17    u.name,
18    u.email,
19    COUNT(o.id)   AS order_count,
20    SUM(o.total)  AS lifetime_value
21FROM users u
22LEFT JOIN orders o
23       ON o.user_id = u.id
24      AND o.status  = 'completed'
25WHERE u.created_at >= '2024-01-01'
26GROUP BY u.id, u.name, u.email
27ORDER BY lifetime_value DESC
28LIMIT 50;
29"""
30
31with bs.App(title="CodeEditor Demo", size=(800, 700)) as app:
32    with bs.ScrollView(grow=True, horizontal="stretch"):
33        with bs.Column(padding=20, gap=16, horizontal_items="stretch"):
34
35            # Python syntax highlighting
36            bs.Label("Python", font="heading-sm")
37            bs.CodeEditor(value=SAMPLE_PY, language="python", height=7)
38
39            # SQL syntax highlighting
40            bs.Label("SQL", font="heading-sm")
41            bs.CodeEditor(value=SAMPLE_SQL, language="sql", height=7)
42
43            # No highlighting
44            bs.Label("Plain text (no language)", font="heading-sm")
45            bs.CodeEditor(value="Hello, World!\nNo syntax highlighting.", height=3)
46
47            # Read only
48            bs.Label("Read Only", font="heading-sm")
49            bs.CodeEditor(value=SAMPLE_PY, language="python", height=6, read_only=True)
50
51            # Undo / redo + dirty tracking
52            bs.Label("Undo / Redo + Dirty Tracking", font="heading-sm")
53            dirty_sig = bs.Signal("not modified")
54            editor = bs.CodeEditor(value=SAMPLE_PY, language="python", height=6)
55            editor.on_modified(lambda e: dirty_sig.set("modified" if editor.is_dirty else "not modified"))
56            with bs.Row(gap=8, horizontal_items="center"):
57                bs.Button("Undo",       on_click=lambda: editor.undo())
58                bs.Button("Redo",       on_click=lambda: editor.redo())
59                bs.Button("Mark saved", on_click=lambda: editor.mark_saved())
60                bs.Label(textsignal=dirty_sig, accent="secondary")
61
62            # Search / replace
63            bs.Label("Search / Replace", font="heading-sm")
64            ed2 = bs.CodeEditor(value=SAMPLE_PY, language="python", height=7)
65            with bs.Row(gap=8, horizontal_items="center"):
66                bs.Button("Find",    on_click=lambda: ed2.show_search())
67                bs.Button("Replace", on_click=lambda: ed2.show_replace())
68
69            # Cursor position
70            bs.Label("Cursor Move Event", font="heading-sm")
71            pos_sig = bs.Signal("line ?, col ?")
72            ed3 = bs.CodeEditor(value=SAMPLE_PY, language="python", height=6)
73
74            def _update_pos(e):
75                line, col = ed3.cursor_position
76                pos_sig.set(f"line {line}, col {col}")
77
78            ed3.on_cursor_move(_update_pos)
79            bs.Label(textsignal=pos_sig, accent="secondary")
80
81app.run()