Terminal.Gui icon indicating copy to clipboard operation
Terminal.Gui copied to clipboard

Layout DSL to ease composition and separate concerns

Open ebekker opened this issue 6 years ago • 9 comments

Would there be any value in crafting a domain-specific language to define layouts?

Using a DSL you could separate the layout, child nesting, and default argument/property/handler assignments more naturally, and then focus on behavior and logic separately in say a subclass or partial class.

As a proof-of-concept, I hacked together a quick DSL using PowerShell. Here's a sample of the language:

Example2.tui.ps1:

Import-Module ..\DSL\Terminal.UI.DSL -Force

$winTitle = "Terminal UI via DSL"
$winSubtitle = "Example #2"

Layout {
    MenuBar -Name mainMenuBar {
        MenuBarItem "File" {
            MenuItem "Open" | -handle Action
            MenuItem "Close" | -handle Action
            MenuItem "Exit" | -handle Action OnExit
        }
    }

    Window -Name mainWindow "$winTitle`: $winSubtitle" {
        -set X 0
        -set Y 1
        -set Width -Expr "Dim.Fill()"
        -set Height -Expr "Dim.Fill() - 2"

        ## Name fields
        Label     -X 5  -Y 1 "First:"
        TextField -X 15 -Y 1 -Width 15
        Label     -X 5  -Y 3 "Last:"
        TextField -X 15 -Y 3 -Width 15

        ## Collect a Password
        Label     -X 35 -Y 1 "Password:"
        TextField -X 45 -Y 1 -Width 15 -Secret
        Label     -X 35 -Y 3 "Again:"
        TextField -X 45 -Y 3 -Width 15 -Secret

        ## ToS Languge and Acceptance
        FrameView -Title "ToS:" -Bounds "new Rect(5, 6, 25, 5)" {
            TextView -Text "This is a block of hard to read\nlegalese text\nfor your review." `
                -Frame "new Rect(0, 0, 23, 3)"
        }
        Checkbox -X 7 -Y 12 "Accepted" -Name acceptedCheckBox
        Checkbox -X 7 -Y 13 "Skimmed" -Checked

        ## Captures date of ToS Acceptance
        Label -X 45 -Y 15 "Accepted Date:"
        Label -Frame "new Rect(60, 15, 20, 1)" " " -Name acceptedDateLabel

        ## Submit or Abort
        Button -Name okButton     -X 10 -Y 18 "OK" -IsDefault |
            -handle Clicked
        Button -Name cancelButton -X 20 -Y 18 "Cancel" |
            -handle Clicked

        ## Graceful Exit -- reuses existing handler as menu item
        Button -Text "Exit" {
            -set X -Expr "Pos.Right(mainWindow) - 10"
            -set Y -Expr "Pos.Bottom(mainWindow) - 5"
        } | -handle Clicked OnExit

    }
}

In this PoC, the DSL gets translated at build-time into a 1 or 2 partial classes, the primary class defines the UI layout and element configuration and relationships. For the example above, this is the generated code:

Example2.tui.cs:

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//     Runtime Version:4.0.30319.42000
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

namespace DSL.Example
{
    using System;
    using Mono.Terminal;
    using Terminal.Gui;
    
    
    public partial class Example2
    {
        
        private MenuBar mainMenuBar;
        
        private Window mainWindow;
        
        private CheckBox acceptedCheckBox;
        
        private Label acceptedDateLabel;
        
        private Button okButton;
        
        private Button cancelButton;
        
        partial void okButton_Clicked();
        
        partial void cancelButton_Clicked();
        
        partial void OnExit();
        
        partial void MenuItem1_Action();
        
        partial void MenuItem2_Action();
        
        private void InitLayout()
        {
            MenuItem MenuItem1 = new MenuItem("Open", "", null);
            MenuItem MenuItem2 = new MenuItem("Close", "", null);
            MenuItem MenuItem3 = new MenuItem("Exit", "", null);
            MenuBarItem MenuBarItem1 = new MenuBarItem("File", new MenuItem[] {
                        MenuItem1,
                        MenuItem2,
                        MenuItem3});
            this.mainMenuBar = new MenuBar(new MenuBarItem[] {
                        MenuBarItem1});
            this.mainWindow = new Window("Terminal UI via DSL: Example #2");
            Label Label1 = new Label(5, 1, "First:");
            TextField TextField1 = new TextField(15, 1, 15, "");
            Label Label2 = new Label(5, 3, "Last:");
            TextField TextField2 = new TextField(15, 3, 15, "");
            Label Label3 = new Label(35, 1, "Password:");
            TextField TextField3 = new TextField(45, 1, 15, "");
            Label Label4 = new Label(35, 3, "Again:");
            TextField TextField4 = new TextField(45, 3, 15, "");
            FrameView FrameView1 = new FrameView(new Rect(5, 6, 25, 5), "ToS:");
            TextView TextView1 = new TextView(new Rect(0, 0, 23, 3));
            this.acceptedCheckBox = new CheckBox(7, 12, "Accepted");
            CheckBox CheckBox1 = new CheckBox(7, 13, "Skimmed", true);
            Label Label5 = new Label(45, 15, "Accepted Date:");
            this.acceptedDateLabel = new Label(new Rect(60, 15, 20, 1), " ");
            this.okButton = new Button(10, 18, "OK", true);
            this.cancelButton = new Button(20, 18, "Cancel");
            Button Button1 = new Button("Exit");
            // mainWindow
            mainWindow.Add(Label1);
            mainWindow.Add(TextField1);
            mainWindow.Add(Label2);
            mainWindow.Add(TextField2);
            mainWindow.Add(Label3);
            mainWindow.Add(TextField3);
            mainWindow.Add(Label4);
            mainWindow.Add(TextField4);
            mainWindow.Add(FrameView1);
            mainWindow.Add(acceptedCheckBox);
            mainWindow.Add(CheckBox1);
            mainWindow.Add(Label5);
            mainWindow.Add(acceptedDateLabel);
            mainWindow.Add(okButton);
            mainWindow.Add(cancelButton);
            mainWindow.Add(Button1);
            mainWindow.X = 0;
            mainWindow.Y = 1;
            mainWindow.Width = Dim.Fill();
            mainWindow.Height = Dim.Fill() - 2;
            // 
            TextField3.Secret = true;
            // 
            TextField4.Secret = true;
            // 
            FrameView1.Add(TextView1);
            // 
            TextView1.Text = "This is a block of hard to read\nlegalese text\nfor your review.";
            // okButton
            okButton.Clicked = () => okButton_Clicked();
            // cancelButton
            cancelButton.Clicked = () => cancelButton_Clicked();
            // 
            Button1.X = Pos.Right(mainWindow) - 10;
            Button1.Y = Pos.Bottom(mainWindow) - 5;
            Button1.Clicked = () => OnExit();
            // mainMenuBar
            MenuItem1.Action = () => MenuItem1_Action();
            MenuItem2.Action = () => MenuItem2_Action();
            MenuItem3.Action = () => OnExit();
        }
    }
}

This generated code is combined with hand-written logic which is cleanly separated from the UI composition to create more complete app:

Example2.cs:



// This file will be auto-generated if missing with
// sample code, otherwise it will be left untouched
namespace DSL.Example
{
    using System;
    using Mono.Terminal;
    using Terminal.Gui;
    
    public partial class Example2
    {
        public Example2()
        {
            this.InitLayout();
        }

        partial void OnExit()
        {
            if (ConfirmExit())
                Application.Top.Running = false;
        }

        partial void okButton_Clicked()
        {
            if (!acceptedCheckBox.Checked)
            {
                MessageBox.Query(60, 10, "Accept ToS",
                        "You must accept our Terms of Service to continue!", "OK");
            }
            else
            {
                acceptedDateLabel.Text = DateTime.Now.ToString();
            }
        }

        partial void cancelButton_Clicked()
        {
            Application.Top.Running = false;
        }

        static bool ConfirmExit ()
        {
            var n = MessageBox.Query (50, 7, "Quit Demo",
                    "Are you sure you want to quit this demo?", "Yes", "No");
            return n == 0;
        }

        public static void Start()
        {
            var layout = new Example2();

            Application.Init ();

            Application.Top.Add(new View[] {
                layout.mainWindow,
                layout.mainMenuBar,
            });

    		Application.Run ();

        }
    }
}

Which materializes to this:

tui-dsl-example2-ui

Looking for feedback, if this is worth exploring further.

ebekker avatar Aug 07 '18 21:08 ebekker

That is pretty cool! I think that this is something that could be experimented with on top of the core engine.

migueldeicaza avatar Aug 09 '18 00:08 migueldeicaza

Thanks. Yeah, I can finish implementing the remaining components that are available in the core library. And I really should switch it to Roslyn for the code generation, (currently uses CodeDOM).

ebekker avatar Aug 09 '18 03:08 ebekker

I was thinking of creating an F# wrapper around this library long the lines of Elm or Fable along with the unidirectional MVU architecture approach. I'm not sure how pretty this could be made in C# but I'm confident that the F# case should be able to be comparable with Elm which I'm comfortable with. Perhaps in C# with a more extensible tagged template literal feature could be a reasonable approach as I described here.

jpierson avatar Aug 22 '18 03:08 jpierson

Not sure about F#, I have very limited experience there, but you could use interpolated strings in C# to achieve something similar to what you describe in your comment on csharplang.

By treating it as a FormattedString or a IFormattable you could generate a structured object out of a string representation. You don't have the leading tags as in ES6, but you could just simply prefix it with a method call such as:

Func<string, MyTemplate> helloTemplate = (name) => MyTemplate.Build($"<div>Hello {name}</div>");

or suffix it with an extension method, such as (multi-line variation using verbatim string literal):

Func<string, MyTemplate> helloTemplate = (name) => $@"
<div>
  Hello {name}
</div>
".ToMyTemplate();

ebekker avatar Aug 22 '18 16:08 ebekker

Now, while that is a nice way of embedding an internal DSL within C# (or F#?) code directly, one advantage I like with my PWSH-based DSL above is it gives you built-in IntelliSense for free inside of VS Code (and pby VS with the proper PWSH Extension).

ebekker avatar Aug 22 '18 16:08 ebekker

Great points! My assumption is that the F# helpers would end up structuring thing similar to how HTML and other declarative markup systems are rendered in other Standard ML derived languages such as Elm and F# using syntax such as the following.

HTML

<div>
    <label for="firstName"></label>
    <input type="text" name="firstName" />
</div>

Elm

div []
    [ label [ for "firstName" ]
        []
    , input [ name "firstName", type_ "text" ]
        []
    , text ""
    ]

Fable?

div [ ]
    [ label [ HTMLAttr.Custom ("for", "firstName") ]
        [ ]
      input [ Name "firstName"
              Type "text" ] ]

So these examples have the potential of being able to be typed checked without needing any changes to the language or tooling as it is just built off of the the fundamental design of how function calls work in the language. This way no separate markup syntax, embedded template language, or mashups like with JSX are necessary.

jpierson avatar Aug 23 '18 03:08 jpierson

Dear colleagues, IMHO, DSLs are a bad idea when we are dealing with a language as expressive as C#. Gui markup DSLs like HTML and XAML mostly come down to glorified alternative shorthand syntax for constructor invocations and property assignment. For this we pay by having to study additional syntax, adding maintenance overhead of DSL layer and loosing lots of powerful features of a fully fledged programming language. If we look at HTML trends in the last 3 years, specifically React, it moves away from markup towards using a programming language mixed with markup-like elements, with great results.

Between the power of features like 'out variables', 'params', 'using static' etc... we can define our UI in C# that looks like a DSL language:

namespace Designer {

    using Terminal.Gui;
    using static Designer.TerminalGuiDSL;

    public partial class DSLShowcase {

	public View Root;

	public Button DefaultButton;

	public TextField MessageField;

	public void InitView()
	{
             // DSL starts here

	    Window("MyApp", out Root,
		   v => { v.Width = Dim.Percent(100); v.Height = Dim.Percent(100); }

		   , Window("Subwindow1",                                
		   v => { v.X = 10; v.Y = 2; defaultDimensions(v); } 
                   // notice use of function above to set dimension attributes. 
                   // Can easily be used for CSS-like styles

			, TextField(out MessageField,
			tf => { tf.X = 0; tf.Y = 1; tf.Width = Dim.Percent(100);
			    tf.Text = "Eneter some text"; }
			)

		   )

		   , Window("Subwindow2",
		   v => { v.X = 10; v.Y = 8; defaultDimensions(v); }

			, Button("Show text from above", out DefaultButton,
			b => { b.X = 1; b.Y = 1; b.Clicked = DeafaultButtonClicked; })

		   )
	    );

            //DSL ends here

	    void defaultDimensions(View view)
	    {
		view.Width = 50;
		view.Height = 5;
	    }
	}
    }
}

Methods part:

namespace Designer {

    using Terminal.Gui;

    public partial class DSLShowcase {

	public void DeafaultButtonClicked()
	{
	    var n = MessageBox.Query(50, 7,
			    "Click", MessageField.Text.ToString(), "Ok");
	}
    }
}

Result: image

TerminalGuiDSL.cs:

using System;
using System.Linq;
using Terminal.Gui;

namespace Designer {
    public static class TerminalGuiDSL {
	public static Window Window<T>(string name, out T @outVar, Action<Window> attr, params View[] children)
	    where T : View
	{	   
	    var win = new Window(name);
	    attr?.Invoke(win);
	    if(children?.Any() == true) {
		win.Add(children);
	    }

	    @outVar = (T)(View)win;
	    return win;
	}

	public static Window Window(string name, Action<Window> attr, params View[] children)
	{
	    return Window<View>(name, out var _, attr, children);
	}

	public static Window Window(string name, params View[] children)
	{
	    return Window<View>(name, out var _, null, children);
	}

	public static Button Button<T>(string text, out T @outVar, Action<Button> attr = null)
	    where T : View
	{
	    var button = new Button(text);
	    attr?.Invoke(button);

	    @outVar = (T)(View)button;
	    return button;
	}

	public static Button Button<T>(string text, Action<Button> attr = null)
	    where T : View
	{
	    return Button<View>(text, out var _, attr);
	}

	public static TextField TextField<T>(out T @outVar, Action<TextField> attr = null)
	    where T : View
	{
	    var field = new TextField("");
	    attr?.Invoke(field);

	    @outVar = (T)(View)field;
	    return field;
	}
    }
}

With this approach, we don't need to maintain any separate DSL functionality. Mixin behaviors, template, higher order components - C# already does it for us.

IKoshelev avatar Sep 18 '18 23:09 IKoshelev

This is a cool approach

migueldeicaza avatar Sep 29 '18 02:09 migueldeicaza

This project appears to be related to the approach I was describing above for those interested in taking a look.

https://github.com/DieselMeister/Terminal.Gui.Elmish

jpierson avatar May 03 '19 03:05 jpierson