edvin / tornadofx

Lightweight JavaFX Framework for Kotlin
Apache License 2.0
3.68k stars 269 forks source link

CSS DSL #80

Closed ruckustboom closed 8 years ago

ruckustboom commented 8 years ago

Just an idea: should there be a DSL for adding CSS? Either to a node or to the View.

ruckustboom commented 8 years ago

For a node it wouldn't be particularly useful as, in the node's context, all the properties are already accessible.

But it could be nice to have some sort of CSS DSL for the view. Like Kara Framework or something. Just a thought.

edvin commented 8 years ago

That's an interesting idea. It would certainly be possible, even to include every possible property, since it's just CSS 2.1 after all. We could also create some beautiful builders for gradients etc :)

I'm unsure of wether users would prefer this over editing a normal CSS file though. After all, IDE's have pretty spectacular CSS support these days.

edvin commented 8 years ago

If you want to play with this, maybe Kara is a good place to look for inspiration?

ruckustboom commented 8 years ago

I'll look into it more after we finish all the new builders we have in queue.

I agree that CSS files are a much better option for most applications. This would probably only really be used for some quick styling in the new SingleViewApp class. I'm just not sure if that is a use case that would merit such a feature.

edvin commented 8 years ago

I agree it would be very cool :) Other, more important things first though :)

ruckustboom commented 8 years ago

You should mark this as an enhancement, and we can get to it later.

Here is the kara CSS DSL for future reference.

ronsmits commented 8 years ago

Hmmz then why not add SASS?

ruckustboom commented 8 years ago

Do you mean make the DSL look more like SASS or create a SASS parser?

ronsmits commented 8 years ago

Make it look like SASS

edvin commented 8 years ago

@ronsmits I remember something about that from the Kara Framework. Since the DSL is written in Kotlin, we can do even more powerful things than SCSS out of the box, so there is no need to mimic a potentially inferior syntax :) That might sound cocky, but just think of the possibilities when you can call actual Kotlin functions within your stylesheet. Now that's power :)

That might actually be the one solid argument for doing this - a lot more expressive syntax for JavaFX CSS. This might be a killer feature if done right!

ronsmits commented 8 years ago

I agree, go for it

ruckustboom commented 8 years ago

Does anyone have any experience with the Kara framework? I do not, but it would be nice to hear if there is anything about the Kara CSS DSL that should be changed/added/removed.

For example, the overridden render function seems strange to me. Personally I think it would suit TornadoFX better to have the function in the constructor. So instead of (their example):

class DefaultStyles() : Stylesheet() {
    override fun render() {
        s("body") {
            backgroundColor = c("#f0f0f0")
        }
        s("#main") {
            width = 85.percent
            backgroundColor = c("#fff")
            margin = box(0.px, auto)
            padding = box(1.em)
            border = "1px solid #ccc"
            borderRadius = 5.px
        }

        s("input[type=text], textarea") {
            padding = box(4.px)
            width = 300.px
        }
        s("textarea") {
            height = 80.px
        }

        s("table.fields") {
            s("td") {
                padding = box(6.px, 3.px)
            }
            s("td.label") {
                textAlign = TextAlign.right
            }
            s("td.label.top") {
                verticalAlign = VerticalAlign.top
            }
        }
    }
}

it could just be

val styles = Stylesheet().apply {
    s("body") {
        backgroundColor = c("#f0f0f0")
    }
    s("#main") {
        width = 85.percent
        backgroundColor = c("#fff")
        margin = box(0.px, auto)
        padding = box(1.em)
        border = "1px solid #ccc"
        borderRadius = 5.px
    }

    s("input[type=text], textarea") {
        padding = box(4.px)
        width = 300.px
    }
    s("textarea") {
        height = 80.px
    }

    s("table.fields") {
        s("td") {
            padding = box(6.px, 3.px)
        }
        s("td.label") {
            textAlign = TextAlign.right
        }
        s("td.label.top") {
            verticalAlign = VerticalAlign.top
        }
    }
}

and in a View have a css method

init {
    css {
        s("body") {
            backgroundColor = c("#f0f0f0")
        }
        s("#main") {
            width = 85.percent
            backgroundColor = c("#fff")
            margin = box(0.px, auto)
            padding = box(1.em)
            border = "1px solid #ccc"
            borderRadius = 5.px
        }

        s("input[type=text], textarea") {
            padding = box(4.px)
            width = 300.px
        }
        s("textarea") {
            height = 80.px
        }

        s("table.fields") {
            s("td") {
                padding = box(6.px, 3.px)
            }
            s("td.label") {
                textAlign = TextAlign.right
            }
            s("td.label.top") {
                verticalAlign = VerticalAlign.top
            }
        }
    }

    // TODO: Other stuff
}
ruckustboom commented 8 years ago

I've been messing around trying to get a very small proof of concept, but I've run into a problem. It seems JavaFX does not allow you to add raw CSS as a stylesheet, but requires URLs. There is a way around it, but as far as I can tell, it isn't very flexible (e.g. you can only ever add a single stylesheet, and it has to be a string).

edvin commented 8 years ago

Ah, I remember that, I've seen a couple of different workarounds. I'll mess with this a little myself and report back. I also have a couple of ideas about what we can do to make this even more valuable, but I need to think about it a little more before I write something down :)

ruckustboom commented 8 years ago

Do you want me to send you what I have (just a shell for a CSS DSL), or would you prefer a clean approach?

edvin commented 8 years ago

Nah, hang on - I don't really need anything other than a string to play with this for now :)

edvin commented 8 years ago

I think I found a good way to implement a custom URL handler for our stylesheets, without using any hacks, just plain Java APIs :) I'll need a couple of minutes, will post back soon.

edvin commented 8 years ago

OK, so I added a very minimal Stylesheet class that has the render method. We can change this as per your example above, I just did the minimal amount of work to test this now :)

abstract class Stylesheet {
    abstract fun render(): String
}

Create a subclass of stylesheet and return something from render():

class MyTestStylesheet : Stylesheet() {
    // No builder support, faking it for now :)
    override fun render() = ".root { -fx-background: blue }"
}

Now you can import this stylesheet and even make it reload every time your app gains focus:

class CSSTest : SingleViewApp("CSS Test") {
    override val root = HBox(Label("Hello CSS"))

    override fun start(stage: Stage) {
        importStylesheet(MyTestStylesheet::class)
        stage.reloadStylesheetsOnFocus()
        super.start(stage)
    }
}

Now try changing the background to red, recompile and switch back to your app.

Will this do? :)

edvin commented 8 years ago

The handler is very simple, and it is automatically registered for the new and shiny css:// protocol :)

Give it an url to a fully qualified class name that implements the Stylesheet class, and you're good to go:

val css = URL("css://com.mycompany.css.MainStylesheet")

This can be added to a stage manually as well:

scene.stylesheets.add(css.toExternalForm())

The implementation is basically this:

override fun getInputStream(): InputStream {
    val stylesheet = Class.forName(url.host).newInstance() as Stylesheet
    return stylesheet.render().byteInputStream(StandardCharsets.UTF_8)
}

I've commited it now so you can play with it :)

edvin commented 8 years ago

Here is a small screencast of the concept in action:

https://www.youtube.com/watch?v=hpRxwuwpR_o

ruckustboom commented 8 years ago

That's pretty slick :)

I don't think it can be integrated with a DSL very well though, as you wouldn't have a fully qualified class path in that case. Or am I missing something?

edvin commented 8 years ago

That's no problem at all really :) Can you show me some of your code and I'll make it work :)

ruckustboom commented 8 years ago

Nevermind :) I think I figured it out.

edvin commented 8 years ago

Nice. If you run into problems getting a class out of it, we could always just render the stylesheet to a basic64 encoded string and included it in the url itself. It would be trivial to change the urlhandler to handle that. Let me know, and I'll make the change :) Can you work within the CSS.kt that I added?

Would be cool if we could include preliminary CSS support in the next release!

edvin commented 8 years ago

When you do:

init {
    css {
        ...
    }
}

in a View... where do you want this stylesheet applied?

ruckustboom commented 8 years ago

I've got a very basic prototype working. I'll see if I can flesh it out a bit more by the end of the day.

Current issues:

As far as where to apply the stylesheet, I'm not sure. For now I'm just doing it in the overridden start(Stage) method.

ruckustboom commented 8 years ago

I've now got the s(".test-class") { fontSize = 18.px } to work properly (thanks to this answer.) I just have to go add all the possible properties.

ruckustboom commented 8 years ago

Demo so far:

class CssTest : SingleViewApp("CSS Test") {
    override val root = VBox()

    init {
        with(root) {
            prefWidth = 400.0
            prefHeight = 400.0

            label("test") {
                styleClass += "fred"
            }
            hbox {
                styleClass += "box"
                padding = Insets(5.0)
                label("test 2")
            }
            piechart("Imported Fruits") {
                data("Grapefruit", 12.0)
                data("Oranges", 25.0)
                data("Plums", 10.0)
                data("Pears", 22.0)
                data("Apples", 30.0)
            }
        }
    }

    override fun start(stage: Stage) {
        class MyTestStylesheet : Stylesheet() {
            init {
                s(".label") {
                    fontSize = 18.px
                    textFill = "orange"
                }
                s(".label.fred") {
                    textFill = "blue"
                }
                s(".box") {
                    backgroundColor = "green"

                    s(".label") {
                        backgroundColor = "red"
                        textFill = "white"
                    }
                }
            }
        }
        importStylesheet(MyTestStylesheet::class)

        println(MyTestStylesheet())
        super.start(stage)
    }
}

css-tests

Generated CSS:

.label {
    -fx-font-size: 18px;
    -fx-text-fill: orange;
}

.label.fred {
    -fx-text-fill: blue;
}

.box {
    -fx-background-color: green;
}

.box .label {
    -fx-background-color: red;
    -fx-text-fill: white;
}
edvin commented 8 years ago

Fantastic work!! I have some ideas, will post when I get to a computer!

ruckustboom commented 8 years ago

Any ideas are appreciated.

Demo with Mixin (dumb but gets the point across):

class CssTest : SingleViewApp("CSS Test") {
    override val root = VBox()

    init {
        with(root) {
            prefWidth = 400.0
            prefHeight = 400.0

            label("test") {
                styleClass += "fred"
            }
            hbox {
                styleClass += "box"
                label("test 2")
            }
            piechart("Imported Fruits") {
                data("Grapefruit", 12.0)
                data("Oranges", 25.0)
                data("Plums", 10.0)
                data("Pears", 22.0)
                data("Apples", 30.0)
            }
        }
    }

    override fun start(stage: Stage) {
        class MyTestStylesheet : Stylesheet() {
            init {
                val pad = mixin {
                    prop("padding", 5.px)

                    s(".label") {
                        backgroundColor = "red"
                        textFill = "white"
                    }
                }
                s(".label") {
                    +pad
                    fontSize = 18.px
                    textFill = "orange"
                }
                s(".label.fred") {
                    textFill = "blue"
                }
                s(".box") {
                    +pad
                    backgroundColor = "green"
                }
            }
        }
        importStylesheet(MyTestStylesheet::class)

        println(MyTestStylesheet())
        super.start(stage)
    }
}

css-tests-mixin

.label {
    -fx-padding: 5px;
    -fx-font-size: 18px;
    -fx-text-fill: orange;
}

.label .label {
    -fx-background-color: red;
    -fx-text-fill: white;
}

.label.fred {
    -fx-text-fill: blue;
}

.box {
    -fx-padding: 5px;
    -fx-background-color: green;
}

.box .label {
    -fx-background-color: red;
    -fx-text-fill: white;
}
ruckustboom commented 8 years ago

Arbitrarily nested selectors now work

edvin commented 8 years ago

The mixin feature is fantastic. OK, here goes my idea. Look at the Java FX 8 CSS reference for the Labeled class:

https://docs.oracle.com/javase/8/javafx/api/javafx/scene/doc-files/cssref.html#labeled

It basically tells us every possible property that can be specified for .label. It would be fantastic if it was possible to "tell" a style declaration that it will only be applied to a Labeled, so that it would only accept the style properties defined for a labeled:

s("#heading", Labeled::class) {
    underline = true // This is OK
    orientation = Orientation.HORIZONTAL // Compile error, orientation is not defined for Labeled
}

This might be a pipe dream, but I thought it was worth mentioning it. When you have defined all properties in the Labeled, defining the others are much simpler. For example, ButtonBase would extend Labeled and only add the armed pseudo class.

When you don't specify a class, the default would have to be a class that includes all the subclasses, so that these two declarations would mean the same:

s("#heading") {
    // Anything goes
}

s("#heading", AllClasses::class) {
   // Anything goes here too :)
}

Again, I suspect this is not doable, and might not even be worth it, but I wanted to throw it out there if you want to play with it or at least thing about the implications that would have on the model :)

edvin commented 8 years ago

The color properties could also accept javafx.scene.paint.Color instances, to increase type safety, don't you think? All these three could be possible:

s("#heading") {
    backgroundColor = Color.BLACK
    backgroundColor = Color(0.0, 0.0, 0.0)
    backgroundColor = "black"
    backgroundColor = "#000"
}
ruckustboom commented 8 years ago

One potential downside with the limiting (assuming it's possible) could be scope confusion. For example, if the s("#heading", Labeled::class) { ... } was inside something that did have an orientation property, your above example would compile fine, but apply to the outside selection instead of the #heading.

ruckustboom commented 8 years ago

I'll have to look into supporting colors with better type safety. Currently, properties are stored in a MutableMap<String, Any>. I'm not sure how to add good type safety with that.

An example property: var fontSize: Any by map(properties, "-fx-font-size")

edvin commented 8 years ago

I think the scoping issue is possible to avoid, I think Kara solved that with the way they created the class hierarchy (I might be mistaken, but I think I read that somewhere). The reason we have that problem in TornadoFX is that we use extension functions. This can be avoided for a "evergreen" DSL like this one.

Could I have a look at what you have so far? Easier to give informed advice that way :)

ruckustboom commented 8 years ago

Sure thing. Here is the link to the working branch. I try to keep it up to date as I go.

It's pretty bare bones (just testing out ideas) and the names could be better :)

thomasnield commented 8 years ago

I haven't done CSS too much with JavaFX, but I really like where this is going. I am biased towards writing all things in code so I'll probably be a huge user of this.

ruckustboom commented 8 years ago

I'm not married to what I've done so far, so if throwing it out and starting from scratch would give a better result, I'm all for it.

edvin commented 8 years ago

OK, I'll play with a little and see how it feels and report back :)

edvin commented 8 years ago

I'm afraid I'm just too tired to do any more today :) Will try to look more at it tomorrow!

ruckustboom commented 8 years ago

Is the current selector syntax good:

s(".bad") {
    +mixin
    textFill = "red"
    s(".label") {
        backgroundColor = "black"
    }
}

or would an infix be better:

".bad" style {
    +mixin
    textFill = "red"
    ".label" style {
        backgroundColor = "black"
    }
}
edvin commented 8 years ago

The current syntax is good IMHO :)

edvin commented 8 years ago

I have an idea about how to add type safety. First, change the map function to this:

inline fun <reified V> PropertyChunk.map(key: String): ReadWriteProperty<PropertyChunk, V> {
    return object : ReadWriteProperty<PropertyChunk, V> {
        override fun getValue(thisRef: PropertyChunk, property: KProperty<*>) = properties[key] as V

        override fun setValue(thisRef: PropertyChunk, property: KProperty<*>, value: V) {
            properties[key] = value as Any
        }
    }
}

Now the call site gets prettier, and it supports types:

var fontSize: Any by map("-fx-font-size")

If you want to specify type, do:

var backgroundColor: Color by map("-fx-background-color")

Now, setting a Color from a string gets a bit more verbose, so introduce a c() function in PropertyChunk that creates a color from a string, like Kara does.

The next issue that arises from this change is that the buildString function will now do color.toString() which I suspect will not give us the result we want. To fix this, simply check the type of $value in the buildString method, and construct the right kind of string based on the type. This should be a small subset of types to check for.

Btw, I think we should rename the map function to something more specific to avoid confusion/collision. It could also be defined in the PropertyChunk class to reduce its exposure. Maybe cssprop or something

What do you think?

ruckustboom commented 8 years ago

That sounds great. I'll try it out and see how it goes.

As far as renaming goes, the StyleChunk and PropertyChunk names are just placeholders I used because I didn't feel like thinking of better names at the time. Any suggestions for better names.

ruckustboom commented 8 years ago

So far is seems to work fantastically!

Just a heads up that I'm rendering Colors as rgba(...) instead of #... as JavaFX Colors have opacity. I'll have to look into what I can do with other types (including enums).

edvin commented 8 years ago

Cool :) This is turning out really great :)

ruckustboom commented 8 years ago

Obligatory demo:

class CssTest : SingleViewApp("CSS Test") {
    override val root = VBox()

    init {
        with(root) {
            prefWidth = 400.0
            prefHeight = 400.0

            hbox {
                styleClass += "box"

                label("Alice")
                label("Bob")
            }
        }
    }

    override fun start(stage: Stage) {
        class MyTestStylesheet : Stylesheet() {
            init {
                val pad = mixin {
                    padding = 5.px
                }

                s(".box") {
                    +pad
                    backgroundColor = Color.BLUE
                    spacing = 5.px

                    s(".label") {
                        +pad
                        fontSize = 18
                        textFill = Color.WHITE
                        backgroundColor = Color.GREEN
                    }
                }
            }
        }
        importStylesheet(MyTestStylesheet::class)

        println(MyTestStylesheet())
        super.start(stage)
    }
}

css-tests-type-safety

.box {
    -fx-padding: 5px;
    -fx-background-color: rgba(0, 0, 255, 1.0);
    -fx-spacing: 5px;
}
.box .label {
    -fx-padding: 5px;
    -fx-font-size: 18;
    -fx-text-fill: rgba(255, 255, 255, 1.0);
    -fx-background-color: rgba(0, 128, 0, 1.0);
}
ruckustboom commented 8 years ago

If there is an error parsing the color string (with the c(String) function), what should I default to?

Options:

edvin commented 8 years ago

If you enter an invalid property value in a javafx css stylesheet, the directive is ignored and an error is logged. Therefore I think we should use the same approach.