diff --git a/pyproject.toml b/pyproject.toml index 8d81f0e..3e46a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/sksmithy/_static/tui.tcss b/sksmithy/_static/tui.tcss index 4e38a73..46924ce 100644 --- a/sksmithy/_static/tui.tcss +++ b/sksmithy/_static/tui.tcss @@ -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%; } diff --git a/sksmithy/tui/_components.py b/sksmithy/tui/_components.py index 519526e..2f2baf6 100644 --- a/sksmithy/tui/_components.py +++ b/sksmithy/tui/_components.py @@ -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 @@ -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 @@ -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): @@ -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, ) @@ -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.""" diff --git a/sksmithy/tui/_tui.py b/sksmithy/tui/_tui.py index 02b23ff..ca8d0bf 100644 --- a/sksmithy/tui/_tui.py +++ b/sksmithy/tui/_tui.py @@ -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, @@ -19,6 +19,7 @@ PredictProba, Required, SampleWeight, + SaveButton, Sidebar, ) @@ -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.""" @@ -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"), ] @@ -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(), @@ -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() diff --git a/tests/test_tui.py b/tests/test_tui.py index 26bd0bc..2ef772e 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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: @@ -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()