Skip to content

Commit

Permalink
feat: tui code editor
Browse files Browse the repository at this point in the history
  • Loading branch information
FBruzzesi committed Jun 13, 2024
1 parent d192c45 commit b975759
Show file tree
Hide file tree
Showing 5 changed files with 94 additions and 37 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Website = "https://sklearn-smithy.streamlit.app/"

[project.optional-dependencies]
streamlit = ["streamlit>=1.34.0"]
textual = ["textual>=0.65.0"]
textual = ["textual[syntax]>=0.65.0"]

all = [
"streamlit>=1.34.0",
Expand Down
14 changes: 7 additions & 7 deletions sksmithy/_static/tui.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,20 @@ Input.-valid:focus {
}

ForgeRow {
grid-size: 5 2;
grid-size: 4 1;
grid-gutter: 1;
grid-rows: 1fr 1fr 1fr 2fr;
grid-columns: 1fr;
min-height: 40vh;
grid-columns: 45% 10% 10% 25%;
min-height: 15vh;
max-height: 15vh;
}

ForgeButton {
row-span: 2;
TextArea {
min-height: 15vh;
max-height: 100vh;
}

DestinationFile {
column-span: 2;
row-span: 2;
height: 100%;
}

Expand Down
65 changes: 45 additions & 20 deletions sksmithy/tui/_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from textual import on
from textual.app import ComposeResult
from textual.containers import Container, Grid, Horizontal, ScrollableContainer
from textual.widgets import Button, Input, Select, Static, Switch
from textual.widgets import Button, Collapsible, Input, Select, Static, Switch, TextArea

from sksmithy._models import EstimatorType
from sksmithy._parsers import check_duplicates, name_parser, params_parser
Expand Down Expand Up @@ -206,22 +206,11 @@ def compose(self: Self) -> ComposeResult:
)


class DestinationFile(Container):
"""Destination file input component."""

def compose(self: Self) -> ComposeResult:
yield Prompt(PROMPT_OUTPUT, classes="label")
yield Input(placeholder="mightyestimator.py", id="output-file")


class ForgeButton(Container):
"""forge button component."""

def compose(self: Self) -> ComposeResult:
yield Button.success(
label="Forge ⚒️",
id="forge-btn",
)
yield Button(label="Forge ⚒️", id="forge-btn", variant="success")

@on(Button.Pressed, "#forge-btn")
def on_forge(self: Self, _: Button.Pressed) -> None: # noqa: C901
Expand All @@ -237,7 +226,8 @@ def on_forge(self: Self, _: Button.Pressed) -> None: # noqa: C901
predict_proba = self.app.query_one("#predict_proba", Switch).value
decision_function = self.app.query_one("#decision_function", Switch).value

output_file = self.app.query_one("#output-file", Input).value
code_area = self.app.query_one("#code-area", TextArea)
code_editor = self.app.query_one("#code-editor", Collapsible)

match name_parser(name_input):
case Ok(name):
Expand Down Expand Up @@ -269,13 +259,10 @@ def on_forge(self: Self, _: Button.Pressed) -> None: # noqa: C901
if required_is_valid and optional_is_valid and (msg_duplicated_params := check_duplicates(required, optional)):
errors.append(msg_duplicated_params)

if not output_file:
errors.append("Outfile file cannot be empty!")

if errors:
self.notify(
message="\n".join([f"- {e}" for e in errors]),
title="Invalid inputs",
title="Invalid inputs!",
severity="error",
timeout=5,
)
Expand All @@ -293,20 +280,58 @@ def on_forge(self: Self, _: Button.Pressed) -> None: # noqa: C901
tags=None,
)

code_area.text = forged_template
code_editor.collapsed = False

self.notify(
message="Template forged!",
title="Success!",
severity="information",
timeout=5,
)


class SaveButton(Container):
"""forge button component."""

def compose(self: Self) -> ComposeResult:
yield Button(label="Save 📂", id="save-btn", variant="primary")

@on(Button.Pressed, "#save-btn")
def on_save(self: Self, _: Button.Pressed) -> None:
output_file = self.app.query_one("#output-file", Input).value

if not output_file:
self.notify(
message="Outfile filename cannot be empty!",
title="Invalid filename!",
severity="error",
timeout=5,
)
else:
destination_file = Path(output_file)
destination_file.parent.mkdir(parents=True, exist_ok=True)

code = self.app.query_one("#code-area", TextArea).text

with destination_file.open(mode="w") as destination:
destination.write(forged_template)
destination.write(code)

self.notify(
message=f"Template forged at {destination_file}",
message=f"Saved at {destination_file}",
title="Success!",
severity="information",
timeout=5,
)


class DestinationFile(Container):
"""Destination file input component."""

def compose(self: Self) -> ComposeResult:
yield Input(placeholder=PROMPT_OUTPUT, id="output-file")


class ForgeRow(Grid):
"""Row grid for forge."""

Expand Down
32 changes: 28 additions & 4 deletions sksmithy/tui/_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer
from textual.reactive import reactive
from textual.widgets import Button, Footer, Header, Rule, Static
from textual.widgets import Button, Collapsible, Footer, Header, Rule, Static, TextArea

from sksmithy.tui._components import (
DecisionFunction,
Expand All @@ -19,6 +19,7 @@
PredictProba,
Required,
SampleWeight,
SaveButton,
Sidebar,
)

Expand All @@ -28,6 +29,12 @@
from typing_extensions import Self


TEXT = """\
import pandas as pd
import numpy as np
"""


class ForgeTUI(App):
"""Textual app to forge scikit-learn compatible estimators."""

Expand All @@ -38,6 +45,7 @@ class ForgeTUI(App):
("ctrl+d", "toggle_sidebar", "Description"),
("L", "toggle_dark", "Light/Dark mode"),
("F", "forge", "Forge"),
("ctrl+s", "save", "Save"),
("E", "app.quit", "Exit"),
]

Expand All @@ -61,14 +69,25 @@ def compose(self: Self) -> ComposeResult:
Horizontal(PredictProba(), DecisionFunction()),
Rule(),
ForgeRow(
Static(),
Static(),
ForgeButton(),
SaveButton(),
DestinationFile(),
Static(),
Static(),
),
Rule(),
Collapsible(
TextArea(
text="",
language="python",
theme="vscode_dark",
show_line_numbers=True,
tab_behavior="indent",
id="code-area",
),
title="Code Editor",
collapsed=True,
id="code-editor",
),
),
Sidebar(classes="-hidden"),
Footer(),
Expand All @@ -95,6 +114,11 @@ def action_forge(self: Self) -> None:
forge_btn = self.query_one("#forge-btn", Button)
forge_btn.press()

def action_save(self: Self) -> None:
"""Press save button."""
save_btn = self.query_one("#save-btn", Button)
save_btn.press()


if __name__ == "__main__": # pragma: no cover
tui = ForgeTUI()
Expand Down
18 changes: 13 additions & 5 deletions tests/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,12 @@ async def test_forge_raise() -> None:

assert "Name cannot be empty!" in m3
assert "Estimator cannot be empty!" in m3
assert "Outfile file cannot be empty!" in m3
assert "Found repeated parameters!" in m3
assert "The following parameters are invalid python identifiers: ('b b',)" in m3


@pytest.mark.parametrize("use_binding", [True, False])
async def test_forge(tmp_path: Path, name: str, estimator: EstimatorType, use_binding: bool) -> None:
async def test_forge_and_save(tmp_path: Path, name: str, estimator: EstimatorType, use_binding: bool) -> None:
"""Test forge button and all of its interactions."""
app = ForgeTUI()
async with app.run_test(size=None) as pilot:
Expand All @@ -168,9 +167,18 @@ async def test_forge(tmp_path: Path, name: str, estimator: EstimatorType, use_bi
else:
forge_btn = pilot.app.query_one("#forge-btn", Button)
forge_btn.action_press()
await pilot.pause()
await pilot.pause()

if use_binding:
await pilot.press("ctrl+s")
else:
save_btn = pilot.app.query_one("#save-btn", Button)
save_btn.action_press()
await pilot.pause()

m1, m2 = (n.message for n in pilot.app._notifications) # noqa: SLF001

notification = next(iter(pilot.app._notifications)) # noqa: SLF001
assert "Template forged!" in m1
assert "Saved at" in m2

assert f"Template forged at {output_file!s}" in notification.message
assert output_file.exists()

0 comments on commit b975759

Please sign in to comment.