From 2e6656a24e3fa3903778afd897345c5390135402 Mon Sep 17 00:00:00 2001 From: Dalia Shaaban <144673861+dshaaban01@users.noreply.github.com> Date: Wed, 8 Nov 2023 16:24:25 +0000 Subject: [PATCH] interactive: App (#1759) Using Textual Python, I have created a GUI/app.This app allows you to write/paste3 xDSL IR into the "Input" TextArea, which is mirrored in the "Output" TextArea. This app is still under construction, this is the first step towards building the full tool. --------- Co-authored-by: Sasha Lopoukhine --- .vscode/launch.json | 7 ++ pyproject.toml | 2 +- requirements.txt | 2 + tests/interactive/test_app.py | 68 +++++++++++++++++ xdsl/interactive/__init__.py | 0 xdsl/interactive/app.py | 139 ++++++++++++++++++++++++++++++++++ xdsl/interactive/app.tcss | 16 ++++ 7 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/interactive/test_app.py create mode 100644 xdsl/interactive/__init__.py create mode 100644 xdsl/interactive/app.py create mode 100644 xdsl/interactive/app.tcss diff --git a/.vscode/launch.json b/.vscode/launch.json index f0d67e01f2..b686b85e42 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,5 +24,12 @@ "${file}" ] }, + { + "name": "interactive", + "type": "python", + "request": "launch", + "module": "xdsl.interactive.app", + "justMyCode": false, + }, ] } diff --git a/pyproject.toml b/pyproject.toml index a9f01494df..a88c4bf4a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -extras = ["riscemu==2.2.5", "wgpu==0.11.0"] +extras = ["riscemu==2.2.5", "wgpu==0.11.0", "textual==0.40.0", "pyclip==0.7"] [project.urls] Homepage = "https://xdsl.dev/" diff --git a/requirements.txt b/requirements.txt index 525f90a05a..36d3323573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,8 @@ ruff==0.1.4 asv<0.7 isort==5.12.0 nbconvert>=7.7.2,<8.0.0 +textual-dev==1.2.1 +pytest-asyncio==0.21.1 # pyright version has to be fixed with `==`. The CI parses this file # and installs the according version for typechecking. pyright==1.1.334 diff --git a/tests/interactive/test_app.py b/tests/interactive/test_app.py new file mode 100644 index 0000000000..241a790250 --- /dev/null +++ b/tests/interactive/test_app.py @@ -0,0 +1,68 @@ +import pytest + +from xdsl.builder import ImplicitBuilder +from xdsl.dialects import arith, func +from xdsl.dialects.builtin import IndexType, IntegerAttr, ModuleOp +from xdsl.interactive.app import InputApp +from xdsl.ir import Block, Region +from xdsl.utils.exceptions import ParseError + + +@pytest.mark.asyncio() +async def test_input(): + """Test pressing keys has the desired result.""" + app = InputApp() + async with app.run_test() as pilot: # pyright: ignore + # Test no input + assert app.output_text_area.text == "No input" + assert app.current_module is None + + # Test inccorect input + app.input_text_area.insert("dkjfd") + await pilot.pause() + assert ( + app.output_text_area.text + == "(Span[5:6](text=''), 'Operation builtin.unregistered does not have a custom format.')" + ) + assert isinstance(app.current_module, ParseError) + assert ( + str(app.current_module) + == "(Span[5:6](text=''), 'Operation builtin.unregistered does not have a custom format.')" + ) + + # Test corect input + app.input_text_area.clear() + app.input_text_area.insert( + """ + func.func @hello(%n : index) -> index { + %two = arith.constant 2 : index + %res = arith.muli %n, %two : index + func.return %res : index + } + """ + ) + await pilot.pause() + assert ( + app.output_text_area.text + == """builtin.module { + func.func @hello(%n : index) -> index { + %two = arith.constant 2 : index + %res = arith.muli %n, %two : index + func.return %res : index + } +} +""" + ) + + index = IndexType() + + expected_module = ModuleOp(Region([Block()])) + with ImplicitBuilder(expected_module.body): + function = func.FuncOp("hello", ((index,), (index,))) + with ImplicitBuilder(function.body) as (n,): + two = arith.Constant(IntegerAttr(2, index)).result + res = arith.Muli(n, two) + func.Return(res) + + assert isinstance(app.current_module, ModuleOp) + assert app.current_module.is_structurally_equivalent(expected_module) diff --git a/xdsl/interactive/__init__.py b/xdsl/interactive/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/xdsl/interactive/app.py b/xdsl/interactive/app.py new file mode 100644 index 0000000000..24655503d2 --- /dev/null +++ b/xdsl/interactive/app.py @@ -0,0 +1,139 @@ +""" +An interactive command-line tool to explore compilation pipeline construction. + +Execute `xdsl-gui` in your terminal to run it. + +Run `terminal -m xdsl.interactive.app:InputApp --def` to run in development mode. Please be sure to install `textual-dev` to run this command. +""" + +from io import StringIO + +from rich.style import Style +from textual import events, on +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.widgets import Footer, TextArea +from textual.widgets.text_area import TextAreaTheme + +from xdsl.dialects.builtin import ModuleOp +from xdsl.ir import MLContext +from xdsl.parser import Parser +from xdsl.printer import Printer +from xdsl.tools.command_line_tool import get_all_dialects + + +class OutputTextArea(TextArea): + """Used to prevent users from being able to change/alter the Output TextArea""" + + async def _on_key(self, event: events.Key) -> None: + event.prevent_default() + + +class InputApp(App[None]): + """ + Interactive application for constructing compiler pipelines. + """ + + CSS_PATH = "app.tcss" + + BINDINGS = [ + ("d", "toggle_dark", "Toggle dark mode"), + ("q", "quit_app", "Quit"), + ] + + # defines a theme for the Input/Output TextArea's + _DEFAULT_THEME = TextAreaTheme( + name="my_theme_design", + base_style=Style(bgcolor="white"), + syntax_styles={ + "string": Style(color="red"), + "comment": Style(color="magenta"), + }, + ) + + current_module: reactive[ModuleOp | Exception | None] = reactive(None) + """ + Reactive variable used to save the current state of the modified Input TextArea (i.e. is the Output TextArea) + """ + + input_text_area = TextArea(id="input") + output_text_area = OutputTextArea(id="output") + + def compose(self) -> ComposeResult: + """ + Creates the required widgets, events, etc. + Get the list of xDSL passes, add them to an array in "Selection" format (so it can be added to a Selection List) + and sort the list in alphabetical order. + """ + + with Horizontal(id="input_output"): + with Vertical(id="input_container"): + yield self.input_text_area + with Vertical(id="output_container"): + yield self.output_text_area + yield Footer() + + @on(TextArea.Changed, "#input") + def update_current_module(self) -> None: + """ + Function called when the Input TextArea is cahnged. This function parses the Input IR and updates + the current_module reactive variable. + """ + input_text = self.input_text_area.text + try: + ctx = MLContext(True) + for dialect in get_all_dialects(): + ctx.load_dialect(dialect) + parser = Parser(ctx, input_text) + module = parser.parse_module() + self.current_module = module + except Exception as e: + self.current_module = e + + def watch_current_module(self): + """ + Function called when the current_module reactive variable is updated. This function updates + the Output TextArea. + """ + match self.current_module: + case None: + output_text = "No input" + case Exception() as e: + output_stream = StringIO() + Printer(output_stream).print(e) + output_text = output_stream.getvalue() + case ModuleOp(): + output_stream = StringIO() + Printer(output_stream).print(self.current_module) + output_text = output_stream.getvalue() + + self.output_text_area.load_text(output_text) + + def on_mount(self) -> None: + """Configure widgets in this application before it is first shown.""" + + # register's the theme for the Input/Output TextArea's + self.input_text_area.register_theme(InputApp._DEFAULT_THEME) + self.output_text_area.register_theme(InputApp._DEFAULT_THEME) + self.input_text_area.theme = "my_theme_design" + self.output_text_area.theme = "my_theme_design" + + self.query_one("#input_container").border_title = "Input xDSL IR" + self.query_one("#output_container").border_title = "Output xDSL IR" + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + def action_quit_app(self) -> None: + """An action to quit the app.""" + self.exit() + + +def main(): + return InputApp().run() + + +if __name__ == "__main__": + main() diff --git a/xdsl/interactive/app.tcss b/xdsl/interactive/app.tcss new file mode 100644 index 0000000000..364ccd628c --- /dev/null +++ b/xdsl/interactive/app.tcss @@ -0,0 +1,16 @@ + +# Vertical(TextArea) +#input_container { + margin: 1; + border: heavy $warning-lighten-1; + border-title-color: $primary; + border-title-align: center; +} + +# Vertical(OutputTextArea) +#output_container { + margin: 1; + border: heavy $warning-lighten-1; + border-title-color: $primary; + border-title-align: center; +}