snakelit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
snakelit/__init__.py ADDED
File without changes
snakelit/edit.py ADDED
@@ -0,0 +1,43 @@
1
+ """Store-backed file editors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from .filetypes import BINARY_SUFFIXES, IMAGE_SUFFIXES, suffix
8
+ from .types import Editors, FileStore
9
+
10
+
11
+ def text_edit(text: str, key: str):
12
+ """Render an editable text area and return its current value.
13
+
14
+ The key comes from whoever calls this (the file manager passes the path),
15
+ so multiple editable views never collide.
16
+ """
17
+ return st.text_area("Content", value=text, key=f"{key}-text", height=420)
18
+
19
+
20
+ def edit_for(path: str, store: FileStore, key: str, *, editors: Editors | None = None):
21
+ """Render ``path``'s editor and write the result back to ``store``.
22
+
23
+ The editor is chosen by suffix: a host-provided one from ``editors`` when
24
+ available, a plain text area for other text, nothing for images and
25
+ binaries. A host editor returns the original bytes when the file did not
26
+ change, so nothing is written then. Missing files start empty, so the
27
+ same call creates them; a successful change writes and reruns.
28
+ """
29
+ content = store.read(path) or b""
30
+ ext = suffix(path)
31
+ if editors and ext in editors:
32
+ edited = editors[ext](path, content, key)
33
+ elif ext in IMAGE_SUFFIXES or ext in BINARY_SUFFIXES:
34
+ return
35
+ else:
36
+ try:
37
+ text = content.decode("utf-8")
38
+ except UnicodeDecodeError:
39
+ return
40
+ edited = text_edit(text=text, key=key).encode("utf-8")
41
+ if edited != content:
42
+ store.write(path, edited)
43
+ st.rerun()
File without changes
@@ -0,0 +1,325 @@
1
+ """Config editors driven by a JSON schema.
2
+
3
+ ``schema_config`` renders a config document as widgets and rebuilds it from the
4
+ widget values on every rerun, so it never mutates the caller's object. The
5
+ schema is optional and read-only: with one, widgets and validation follow it;
6
+ without one, the config is edited as raw YAML. Only mappings get the form;
7
+ other YAML documents (lists, scalars) fall back to the text editor. Hosts
8
+ discover or let the user choose the schema; nothing in here edits or writes it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from typing import Any, Callable, Mapping
15
+
16
+ import streamlit as st
17
+
18
+ from ..utils.st_layouts import st_container_maxheight
19
+ from ..utils.schema import infer_type, is_schema, property_schema, validate
20
+ from ..utils.yaml import SCIENTIFIC_FLOAT, dump as dump_yaml, load
21
+ from .text import schema_text_editor
22
+
23
+
24
+ def _dump(data, format: str):
25
+ if format == "yaml":
26
+ return dump_yaml(data)
27
+ from ..utils.json import dump as dump_json
28
+
29
+ return dump_json(data)
30
+
31
+
32
+ def _looks_like_path(value: str):
33
+ if not value or "://" in value or any(char.isspace() for char in value):
34
+ return False
35
+ return "/" in value or bool(re.search(r"\.[A-Za-z][A-Za-z0-9]*$", value))
36
+
37
+
38
+ def schema_config(
39
+ data: bytes,
40
+ errors: list[str],
41
+ schema: Mapping[str, Any] | None = None,
42
+ header="Configuration Editor",
43
+ *,
44
+ key: str = "config",
45
+ edit_file: Callable[[str], None] | None = None,
46
+ format: str = "yaml",
47
+ ):
48
+ """Render an editor for a config file and return the new bytes.
49
+
50
+ Widgets follow ``schema`` where it describes a key and are inferred from
51
+ the value elsewhere; a raw YAML editor is always offered as the
52
+ alternative. ``errors`` is filled in place. Returns ``None`` when the
53
+ text did not change or is not valid, so the caller can skip writing it.
54
+
55
+ With ``edit_file``, a string value that looks like a path gets a button
56
+ that calls it with the path string; the host decides whether to create,
57
+ open or ignore the file. ``format`` is ``"yaml"`` or ``"json"`` and
58
+ selects the serializer (parsing uses the YAML loader for both).
59
+ """
60
+ text = data.decode("utf-8")
61
+ height = 420
62
+ probe: list[str] = []
63
+ config = load(text, probe)
64
+ if config is None and not probe:
65
+ config = {}
66
+
67
+ if isinstance(config, dict):
68
+ mode = st.radio(
69
+ header,
70
+ ["Form", "Text Editor"],
71
+ help="Configuration editor mode",
72
+ horizontal=True,
73
+ key=f"{key}-editor-mode",
74
+ )
75
+ if mode == "Form":
76
+ # Compare canonical YAML, not the source text: comments and
77
+ # formatting must survive an untouched form.
78
+ original = _dump(config, format)
79
+ with st_container_maxheight(f"{key}-form-container", height=height):
80
+ create_form(config, schema, "", key, edit_file)
81
+ before = len(errors)
82
+ errors.extend(validate(config, schema))
83
+ if len(errors) > before:
84
+ return None
85
+ updated = _dump(config, format)
86
+ return None if updated == original else updated.encode("utf-8")
87
+
88
+ # Unparseable input is still editable: the text area is the source of
89
+ # truth and reports its own parse/validation errors.
90
+ updated = schema_text_editor(text, schema, key, height=height)
91
+ probe.clear()
92
+ parsed = load(updated, probe)
93
+ if not probe:
94
+ probe.extend(validate(parsed, schema) if parsed else ())
95
+ if not probe:
96
+ return None if updated == text else updated.encode("utf-8")
97
+ errors.extend(probe)
98
+
99
+
100
+ def create_form(
101
+ config: dict,
102
+ schema: Mapping[str, Any] | None,
103
+ path: str = "",
104
+ key: str = "config",
105
+ edit_file: Callable[[str], None] | None = None,
106
+ ):
107
+ """Render widgets for ``config`` in place, in configuration order.
108
+
109
+ Nested mappings recurse into a bordered container at their own position,
110
+ so the form follows the tree instead of collecting them into tabs. A
111
+ schema entry is used when it exists; otherwise the widget is inferred from
112
+ the value. No schema is constructed or modified.
113
+ """
114
+ required = set((schema or {}).get("required") or ())
115
+
116
+ for name, value in config.items():
117
+ subschema = property_schema(schema, name)
118
+ if isinstance(value, dict):
119
+ with st.container(border=True):
120
+ st.markdown(f"**{name}**")
121
+ if subschema is None or is_schema(subschema):
122
+ create_form(value, subschema, _path(path, name), key, edit_file)
123
+ else:
124
+ config[name] = _raw(value, name, f"{key}-{_path(path, name)}", None)
125
+ continue
126
+ config[name] = _value(
127
+ value,
128
+ subschema or infer_type(value),
129
+ _path(path, name),
130
+ key,
131
+ label=name,
132
+ required=name in required,
133
+ edit_file=edit_file,
134
+ )
135
+
136
+
137
+ def _value(
138
+ value,
139
+ schema: Mapping[str, Any],
140
+ path: str,
141
+ key: str,
142
+ *,
143
+ label: str,
144
+ required: bool,
145
+ edit_file: Callable[[str], None] | None = None,
146
+ ):
147
+ widget_key = f"{key}-{path}"
148
+ help_text = schema.get("description")
149
+ caption = f"{label} *" if required else label
150
+ types = _types(schema)
151
+
152
+ if isinstance(schema.get("enum"), list):
153
+ options = list(schema["enum"])
154
+ if value not in options:
155
+ options = [value, *options]
156
+ return st.selectbox(
157
+ caption,
158
+ options,
159
+ index=options.index(value),
160
+ key=widget_key,
161
+ help=help_text,
162
+ )
163
+
164
+ if isinstance(value, list):
165
+ return _array(value, schema, caption, widget_key, help_text)
166
+
167
+ if isinstance(value, Mapping):
168
+ return _raw(value, caption, widget_key, help_text)
169
+
170
+ if "boolean" in types or (not types and isinstance(value, bool)):
171
+ return st.checkbox(caption, value=bool(value), key=widget_key, help=help_text)
172
+
173
+ if "integer" in types or "number" in types:
174
+ return _number(value, types, caption, widget_key, help_text)
175
+
176
+ if not types and isinstance(value, (int, float)):
177
+ return _number(value, ["number"], caption, widget_key, help_text)
178
+
179
+ if "string" in types or not types or value is None:
180
+ return _text(
181
+ value,
182
+ caption,
183
+ widget_key,
184
+ help_text,
185
+ nullable="null" in types,
186
+ edit_file=edit_file,
187
+ )
188
+
189
+ return _raw(value, caption, widget_key, help_text)
190
+
191
+
192
+ def _text(
193
+ value,
194
+ caption: str,
195
+ key: str,
196
+ help_text: str | None,
197
+ *,
198
+ nullable: bool,
199
+ edit_file: Callable[[str], None] | None = None,
200
+ ):
201
+ text = "" if value is None else str(value)
202
+ if edit_file is None or not _looks_like_path(text):
203
+ edited = st.text_input(caption, value=text, key=key, help=help_text)
204
+ else:
205
+ field, action = st.columns([5, 1], vertical_alignment="bottom")
206
+ edited = field.text_input(caption, value=text, key=key, help=help_text)
207
+ action.button(
208
+ "Edit",
209
+ key=f"{key}-file",
210
+ help=f"Treat `{text}` as a file, and open to edit it",
211
+ on_click=edit_file,
212
+ args=(edited or text,),
213
+ )
214
+ if nullable and not edited.strip():
215
+ return None
216
+ return edited
217
+
218
+
219
+ def _number(value, types: list[str], caption: str, key: str, help_text: str | None):
220
+ # Scientific-notation floats are strings by convention and must stay so.
221
+ if isinstance(value, str) or value is None or not types:
222
+ text = "" if value is None else str(value)
223
+ edited = st.text_input(caption, value=text, key=key, help=help_text)
224
+ return _parse_number(edited)
225
+
226
+ integer = "integer" in types
227
+ number = int(value) if integer else float(value)
228
+ edited = st.number_input(
229
+ caption,
230
+ value=number,
231
+ step=1 if integer else 0.1,
232
+ key=key,
233
+ help=help_text,
234
+ format="%d" if integer else None,
235
+ )
236
+ if edited == value:
237
+ # preserve the original object: an int must not become 2.0 just because
238
+ # the schema allows any number
239
+ return value
240
+ if not integer and isinstance(value, int) and float(edited).is_integer():
241
+ return int(edited)
242
+ return edited
243
+
244
+
245
+ def _parse_number(text: str):
246
+ stripped = text.strip()
247
+ if not stripped:
248
+ return None
249
+ if SCIENTIFIC_FLOAT.match(stripped):
250
+ return stripped
251
+ try:
252
+ return int(stripped)
253
+ except ValueError:
254
+ pass
255
+ try:
256
+ return float(stripped)
257
+ except ValueError:
258
+ return stripped
259
+
260
+
261
+ def _array(
262
+ value: list,
263
+ schema: Mapping[str, Any],
264
+ caption: str,
265
+ key: str,
266
+ help_text: str | None,
267
+ ):
268
+ items = schema.get("items") or {}
269
+ if not isinstance(value, list):
270
+ value = [] if value is None else [value]
271
+
272
+ if (
273
+ is_schema(items)
274
+ or "array" in _types(items)
275
+ or any(isinstance(item, (Mapping, list)) for item in value)
276
+ ):
277
+ parsed = _raw(value, caption, key, help_text)
278
+ return parsed if isinstance(parsed, list) else value
279
+
280
+ text = "\n".join("" if item is None else str(item) for item in value)
281
+ edited = st.text_area(
282
+ f"{caption} (one value per line)",
283
+ value=text,
284
+ key=key,
285
+ help=help_text,
286
+ height=min(200, 40 + 22 * max(1, len(value))),
287
+ )
288
+ parsed = []
289
+ for line in edited.splitlines():
290
+ if not line.strip():
291
+ continue
292
+ try:
293
+ item = load(line)
294
+ except Exception: # noqa: BLE001 - shown to the user
295
+ st.error(f"Invalid value in {caption}: {line!r}")
296
+ return value
297
+ parsed.append(line.strip() if isinstance(item, (dict, list)) else item)
298
+ return parsed
299
+
300
+
301
+ def _raw(value, caption: str, key: str, help_text: str | None):
302
+ edited = st.text_area(
303
+ f"{caption} (YAML)",
304
+ value=dump_yaml(value),
305
+ key=key,
306
+ help=help_text,
307
+ height=160,
308
+ )
309
+ errors = []
310
+ parsed = load(edited, errors)
311
+ if errors:
312
+ st.error(errors[0])
313
+ return value
314
+ return parsed
315
+
316
+
317
+ def _types(schema: Mapping[str, Any]) -> list[str]:
318
+ declared = schema.get("type")
319
+ if isinstance(declared, str):
320
+ return [declared]
321
+ return list(declared or ())
322
+
323
+
324
+ def _path(parent: str, name: str):
325
+ return f"{parent}.{name}" if parent else name
@@ -0,0 +1,332 @@
1
+ """Editable Polars-backed table widgets.
2
+
3
+ ``form_table`` seeds ``st.data_editor`` from the frame it is given; the
4
+ editor's widget state carries the edits, and the caller decides where the
5
+ returned frame goes. ``column_controls`` renders icon add/rename/remove/move
6
+ popovers that replace the frame before it is edited. ``schema_table`` adds
7
+ optional row-schema coercion and validation on top of a bytes value.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Mapping
13
+
14
+ import sys
15
+ import polars as pl
16
+ import streamlit as st
17
+
18
+ from .table_schema import coerce_frame, table_errors
19
+ from .text import schema_text_editor
20
+ from ..utils.table import dump, load
21
+ from ..utils.st_layouts import st_container_maxheight
22
+
23
+ Control = Callable[[str, pl.DataFrame], tuple[pl.DataFrame, Any] | None]
24
+ """A control returns ``(frame, payload)`` when it fired, else ``None``."""
25
+
26
+
27
+ def schema_table(
28
+ data: bytes,
29
+ errors: list[str],
30
+ schema: Mapping[str, Any] | None = None,
31
+ header: str = "Table Editor",
32
+ *,
33
+ key: str = "table",
34
+ format: str = "csv",
35
+ ):
36
+ """Render an editor for a table and return the new bytes.
37
+
38
+ ``format`` is the table's file suffix (``csv``, ``tsv``, ``parquet`` or
39
+ ``xlsx``). Text formats offer a Form/Text switch, binary formats only the
40
+ form. ``errors`` is filled in place. Returns ``None`` when the data did
41
+ not change or is invalid, so the caller can skip writing it.
42
+
43
+ With ``schema``, the form coerces columns to its declared dtypes and
44
+ validates every row; the schema is never edited.
45
+ """
46
+ height = 420
47
+ # A file that cannot be read is not an error yet: the text editor takes
48
+ # over below, so this probe keeps its failures to itself.
49
+ table = load(data, suffix=format, errors=[])
50
+ textable = format in ("csv", "tsv")
51
+ if table is not None:
52
+ if (
53
+ not textable
54
+ or st.radio(
55
+ header,
56
+ ["Form", "Text Editor"],
57
+ help="Table editor mode",
58
+ horizontal=True,
59
+ key=f"{key}-editor-mode",
60
+ )
61
+ == "Form"
62
+ ):
63
+ before = len(errors)
64
+ with st_container_maxheight(f"{key}-form-container", height=height):
65
+ edited = form_table(table, errors, schema, key=key)
66
+ if len(errors) > before:
67
+ return None
68
+ # Compare canonical bytes: coercion changes dtypes without changing
69
+ # the values, and an untouched form must not rewrite the table.
70
+ updated = dump(edited, format)
71
+ return None if updated == dump(table, format) else updated
72
+ elif not textable:
73
+ st.error(f"Cannot edit broken {format.upper()} table as text.")
74
+ return None
75
+
76
+ try:
77
+ text = data.decode("utf-8")
78
+ except UnicodeDecodeError:
79
+ st.error(f"Cannot edit {format.upper()} data as text.")
80
+ return None
81
+ updated = schema_text_editor(text, schema, key, height=height)
82
+ before = len(errors)
83
+ parsed = load(updated.encode("utf-8"), suffix=format, errors=errors)
84
+ if parsed is None:
85
+ return None
86
+ if schema:
87
+ parsed, _ = coerce_frame(parsed, schema, errors)
88
+ errors.extend(table_errors(parsed, schema))
89
+ if len(errors) > before:
90
+ return None
91
+ return None if updated == text else updated.encode("utf-8")
92
+
93
+
94
+ def form_table(
95
+ frame: pl.DataFrame,
96
+ errors: list[str],
97
+ schema: Mapping | None = None,
98
+ *,
99
+ key: str = "table",
100
+ column_config: Mapping | None = None,
101
+ ):
102
+ """Edit ``frame`` and return the edited frame.
103
+
104
+ With ``schema``, cells carry its declared dtypes and every row is validated;
105
+ the schema itself is never edited.
106
+ """
107
+ data_key = f"{key}-data"
108
+ if schema:
109
+ # input frame is loaded without schema (default strings for csv/tsv),
110
+ # so coerce it to the schema before comparing.
111
+ frame, _ = coerce_frame(frame, schema, errors)
112
+
113
+ current: pl.DataFrame | None = st.session_state.get(data_key)
114
+ reset = current is None or not current.equals(frame)
115
+ if reset:
116
+ # The host's frame is the truth: refresh the copy whenever it changed,
117
+ # so an unmounted editor or a document changed elsewhere cannot leave
118
+ # stale rows behind.
119
+ st.session_state[data_key] = frame
120
+ change = column_controls(key)
121
+ edited = editable_table(
122
+ key,
123
+ column_config=column_config,
124
+ reset=reset or bool(change),
125
+ )
126
+
127
+ if schema:
128
+ # strip and format the edited frame to save correctly.
129
+ edited, _ = coerce_frame(edited, schema, errors)
130
+ st.session_state[data_key] = edited
131
+ errors.extend(table_errors(edited, schema))
132
+ return edited
133
+
134
+
135
+ def column_controls(
136
+ key: str,
137
+ *,
138
+ controls: Mapping[str, Control] | None = None,
139
+ extras: Mapping[str, Control] | None = None,
140
+ ):
141
+ """Render column popovers for ``st.session_state[f"{key}-data"]``.
142
+
143
+ Returns the changes applied, keyed by control name. ``controls`` replaces
144
+ the default add/rename/remove/move set; ``extras`` renders more next to it.
145
+ """
146
+ changes: dict[str, Any] = {}
147
+ calls = dict(controls or _default_controls) | dict(extras or {})
148
+ holders = st.columns(len(calls))
149
+ for holder, call in zip(holders, calls):
150
+ with holder:
151
+ ret = calls[call](key, st.session_state[f"{key}-data"])
152
+ if ret is not None:
153
+ st.session_state[f"{key}-data"], changes[call] = ret
154
+ return changes
155
+
156
+
157
+ def editable_table(
158
+ key: str,
159
+ *,
160
+ column_config: Mapping | None = None,
161
+ drop_empty: bool = True,
162
+ reset: bool = False,
163
+ ) -> pl.DataFrame:
164
+ """Render ``st.data_editor`` for ``st.session_state[f"{key}-data"]``.
165
+
166
+ The current data lives in ``{key}-data``; the edited result is written back
167
+ there and returned. Edits accumulate against a separate stable base
168
+ (``{key}-base``) rather than re-feeding ``{key}-data`` as the editor input,
169
+ which would make edits lag by one run. Pass ``reset=True`` when the table
170
+ is replaced programmatically (column change, upload, fill, clear) so the
171
+ base is re-seeded and the widget rebuilt.
172
+ """
173
+ data_key = f"{key}-data"
174
+ base_key = f"{key}-base"
175
+ revision_key = f"{key}-revision"
176
+ if reset or base_key not in st.session_state:
177
+ st.session_state[base_key] = st.session_state[data_key]
178
+ st.session_state[revision_key] = st.session_state.get(revision_key, 0) + 1
179
+ edited = st.data_editor(
180
+ st.session_state[base_key].to_pandas(),
181
+ num_rows="dynamic",
182
+ width="stretch",
183
+ hide_index=True,
184
+ key=f"{key}-{st.session_state[revision_key]}",
185
+ column_config=column_config,
186
+ )
187
+ result = _from_pandas(edited)
188
+ if drop_empty:
189
+ result = _drop_empty_rows(result)
190
+ st.session_state[data_key] = result
191
+ return result
192
+
193
+
194
+ def _from_pandas(frame):
195
+ """Rebuild a Polars frame from the data editor's pandas frame.
196
+
197
+ Polars' own pandas bridge creates string columns that later panic with
198
+ ``capacity overflow`` when converted back to pandas under Pyodide
199
+ (polars 1.33). Going through plain Python values avoids that path.
200
+ """
201
+ if sys.platform != "emscripten":
202
+ return pl.from_pandas(frame)
203
+
204
+ import pandas as pd
205
+
206
+ def cell(value):
207
+ if value is None or value is pd.NA or value is pd.NaT:
208
+ return None
209
+ if isinstance(value, float) and value != value:
210
+ return None
211
+ return value
212
+
213
+ data = {
214
+ name: [cell(value) for value in values]
215
+ for name, values in frame.to_dict(orient="list").items()
216
+ }
217
+ overrides: dict[str, type[pl.DataType]] = {}
218
+ for name, dtype in frame.dtypes.items():
219
+ if pd.api.types.is_bool_dtype(dtype):
220
+ overrides[name] = pl.Boolean
221
+ elif pd.api.types.is_unsigned_integer_dtype(dtype):
222
+ overrides[name] = pl.UInt64
223
+ elif pd.api.types.is_integer_dtype(dtype):
224
+ overrides[name] = pl.Int64
225
+ elif pd.api.types.is_float_dtype(dtype):
226
+ overrides[name] = pl.Float64
227
+ elif not pd.api.types.is_object_dtype(dtype) and pd.api.types.is_string_dtype(
228
+ dtype
229
+ ):
230
+ overrides[name] = pl.Utf8
231
+ elif pd.api.types.is_object_dtype(dtype) and not any(
232
+ value is not None for value in data[name]
233
+ ):
234
+ overrides[name] = pl.Utf8
235
+ return pl.DataFrame(data, schema_overrides=overrides)
236
+
237
+
238
+ def _drop_empty_rows(frame: pl.DataFrame):
239
+ """Drop rows where every cell is null or blank.
240
+
241
+ The mask is applied while rebuilding the frame from Python values instead
242
+ of ``frame.filter``: Polars 1.33 under Pyodide corrupts string columns when
243
+ a filtered frame is converted back to pandas (``capacity overflow``).
244
+ """
245
+ if frame.height == 0 or not frame.columns:
246
+ return frame
247
+ keep = [
248
+ any(
249
+ value is not None and (not isinstance(value, str) or value.strip() != "")
250
+ for value in row
251
+ )
252
+ for row in frame.iter_rows()
253
+ ]
254
+ if all(keep):
255
+ return frame
256
+ data = {
257
+ name: [value for value, wanted in zip(frame[name].to_list(), keep) if wanted]
258
+ for name in frame.columns
259
+ }
260
+ return pl.DataFrame(data, schema=frame.schema)
261
+
262
+
263
+ def _add_column(key: str, frame: pl.DataFrame):
264
+ options = list(frame.columns)
265
+ with st.popover("", icon=":material/add:", help="Add a column"):
266
+ name = st.text_input("Add column", key=f"{key}-new-column")
267
+ clicked = st.button("Add", key=f"{key}-add-column")
268
+ name = (name or "").strip()
269
+ if clicked and name and name not in options:
270
+ return frame.with_columns(pl.Series(name, [""] * frame.height)), name
271
+
272
+
273
+ def _rename_column(key: str, frame: pl.DataFrame):
274
+ options = list(frame.columns)
275
+ with st.popover("", icon=":material/edit:", help="Rename a column"):
276
+ if options:
277
+ selected = st.selectbox(
278
+ "Select column to rename", options, key=f"{key}-rename-column"
279
+ )
280
+ renamed = st.text_input("Rename to", key=f"{key}-rename-column-text")
281
+ clicked = st.button("Rename", key=f"{key}-rename-column-button")
282
+ else:
283
+ st.caption("No columns to rename.")
284
+ selected, renamed, clicked = None, "", False
285
+ new_name = (renamed or "").strip()
286
+ if clicked and new_name and new_name not in options and selected in frame.columns:
287
+ return frame.rename({selected: new_name}), (selected, new_name)
288
+
289
+
290
+ def _delete_column(key: str, frame: pl.DataFrame):
291
+ options = list(frame.columns)
292
+ with st.popover("", icon=":material/remove:", help="Remove a column"):
293
+ if options:
294
+ selected = st.selectbox(
295
+ "Select column to remove", options, key=f"{key}-remove-column"
296
+ )
297
+ clicked = st.button("Remove", key=f"{key}-remove-column-button")
298
+ else:
299
+ st.caption("No columns to remove.")
300
+ selected, clicked = None, False
301
+ if clicked and selected in frame.columns:
302
+ return frame.drop(selected), selected
303
+
304
+
305
+ def _move_column(key: str, frame: pl.DataFrame):
306
+ options = list(frame.columns)
307
+ with st.popover("", icon=":material/swap_horiz:", help="Reorder columns"):
308
+ if len(options) > 1:
309
+ selected = st.selectbox("Move column", options, key=f"{key}-move-column")
310
+ target = st.selectbox(
311
+ "Move before", ["(end)", *options], key=f"{key}-move-target"
312
+ )
313
+ clicked = st.button("Move", key=f"{key}-move-button")
314
+ else:
315
+ st.caption("Need at least two columns to reorder.")
316
+ return None
317
+ if not clicked or selected not in options:
318
+ return None
319
+ order = [column for column in options if column != selected]
320
+ index = len(order) if target == "(end)" else order.index(target)
321
+ order.insert(index, selected)
322
+ if order == options:
323
+ return None
324
+ return frame.select(order), order
325
+
326
+
327
+ _default_controls = {
328
+ "add": _add_column,
329
+ "rename": _rename_column,
330
+ "remove": _delete_column,
331
+ "move": _move_column,
332
+ }