Skip to content

Commit

Permalink
Merge pull request #2977 from gaphor/cascading-css
Browse files Browse the repository at this point in the history
Make style sheets cascade
  • Loading branch information
danyeaw authored Jan 6, 2024
2 parents 0117be6 + c582ef7 commit 24c39a6
Show file tree
Hide file tree
Showing 22 changed files with 482 additions and 340 deletions.
4 changes: 2 additions & 2 deletions docs/style-sheet-examples.gaphor
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<gaphor xmlns="http://gaphor.sourceforge.net/model" version="3.0" gaphor-version="2.22.1">
<gaphor xmlns="http://gaphor.sourceforge.net/model" version="3.0" gaphor-version="2.23.0">
<Package id="9d588440-d7c2-11ea-b8c5-37ef9fca94cd">
<name>
<val>style-sheets</val>
Expand Down Expand Up @@ -96,7 +96,7 @@ diagram[name=draft] * {
font-family: Purisa;
}

:not([subject], :is(line, box, ellipse, commentline)) {
:not(:is(:root, line, box, ellipse, commentline))[subject=""] {
color: firebrick;
}

Expand Down
30 changes: 17 additions & 13 deletions docs/style_sheets.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ of CSS. Below you'll find a summary of all CSS features supported by Gaphor.
- ``:drop`` if an item is dragged and can be dropped on this item
- ``:disabled`` if an element is grayed out during handle movement
``node:empty`` A node containing no child nodes in the diagram.
``:root`` An item is at the top level of the diagram.
``:root`` Refers to the diagram itself.
This is only applicable for the diagram
``:has()`` The item contains any of the provided selectors.
Expand Down Expand Up @@ -132,6 +132,10 @@ widths, heights, and sizes are measured in pixels. You can’t use complex style
declarations, like the `font` property in HTML/CSS which can contain font
family, size, weight.

Some properties are inherited from the parent style. The parent often is a diagram.
When you set a `color`` or a `font-family` on `diagram`, it will propagate down
to the items contained in the diagram.

### Colors

```{eval-rst}
Expand All @@ -143,8 +147,10 @@ family, size, weight.
``background-color: rgb(255, 255, 255);``
``background-color: hsl(130, 95%, 10%);``
``color`` Color used for lines.
``text-color`` Color for text.
``color`` Color used for lines. *(inherited)*
``text-color`` Color for text. *(inherited)*
.. deprecated:: 2.23.0 Use color if possible.
``opacity`` Color opacity factor (``0.0`` - ``1.0``),
applied to all colors.
======================= =======================================
Expand All @@ -158,21 +164,19 @@ family, size, weight.

```{eval-rst}
======================= =======================================
``font-family`` A single font name (e.g. ``sans``, ``serif``, ``courier``).
``font-size`` An absolute size (e.g. ``14``) or a size value (e.g. ``small``).
``font-style`` Either ``normal`` or ``italic``.
``font-weight`` Either ``normal`` or ``bold``.
``text-align`` Either ``left``, ``center``, ``right``.
``font-family`` A single font name (e.g. ``sans``, ``serif``, ``courier``). *(inherited)*
``font-size`` An absolute size (e.g. ``14``) or a size value (e.g. ``small``). *(inherited)*
``font-style`` Either ``normal`` or ``italic``. *(inherited)*
``font-weight`` Either ``normal`` or ``bold``. *(inherited)*
``text-align`` Either ``left``, ``center``, ``right``. *(inherited)*
``text-decoration`` Either ``none`` or ``underline``.
``vertical-align`` Vertical alignment for text.
Either ``top``, ``middle`` or ``bottom``.
``vertical-spacing`` Set vertical spacing for icon-like items (actors, start state).
Example: ``vertical-spacing: 4``.
``white-space`` Change the line wrapping behavior for text.
Some text, like class' attributes and operations, cannot be wrapped.
Either ``normal`` (text is wrapped) or ``nowrap``.
``white-space`` Change the line wrapping behavior for text. *(inherited)*
======================= =======================================
```

Expand All @@ -191,7 +195,7 @@ family, size, weight.
Either ``start``, ``end``, ``center`` or ``stretch``.
``line-style`` Either ``normal`` or ``sloppy [factor]``.
``line-width`` Set the width for lines: ``line-width: 2``.
``line-width`` Set the width for lines: ``line-width: 2``. *(inherited)*
``min-height`` Set minimal height for an item: ``min-height: 50``.
``min-width`` Set minimal width for an item: ``min-width: 100``.
``padding`` CSS style padding (top, right, bottom, left).
Expand Down Expand Up @@ -364,7 +368,7 @@ ends. This rule will exclude simple elements, like lines and boxes, which will
never have a backing model element.

```css
:not([subject], :is(line, box, ellipse, commentline)) {
:not(:is(:root, line, box, ellipse, commentline))[subject=""] {
color: firebrick;
}
```
Expand Down
13 changes: 2 additions & 11 deletions gaphor/C4Model/diagramitems/container.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from gaphor.C4Model import c4model
from gaphor.core.styling import JustifyContent
from gaphor.diagram.presentation import ElementPresentation, Named, text_name
from gaphor.diagram.shapes import Box, CssNode, Text, draw_border
from gaphor.diagram.support import represents
Expand All @@ -26,7 +25,7 @@ def update_shapes(self, event=None):
Text(
text=lambda: self.subject.technology
and f"[{diagram.gettext(self.subject.type)}: {self.subject.technology}]"
or f"[{diagram.gettext(self.subject.type)}]",
or f"[{diagram.gettext(self.subject.type)}]"
),
),
*(
Expand All @@ -36,17 +35,9 @@ def update_shapes(self, event=None):
CssNode(
"description",
self.subject,
Text(
text=lambda: self.subject.description or "",
),
Text(text=lambda: self.subject.description or ""),
),
)
),
style={
"padding": (4, 4, 4, 4),
"justify-content": JustifyContent.END
if self.diagram and self.children
else JustifyContent.CENTER,
},
draw=draw_border,
)
35 changes: 17 additions & 18 deletions gaphor/C4Model/diagramitems/database.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from gaphor.C4Model import c4model
from gaphor.core.styling import JustifyContent, TextAlign
from gaphor.diagram.presentation import ElementPresentation, Named, text_name
from gaphor.diagram.shapes import Box, Text, ellipse, stroke
from gaphor.diagram.shapes import Box, CssNode, Text, ellipse, stroke
from gaphor.diagram.support import represents


Expand All @@ -14,32 +13,32 @@ def __init__(self, diagram, id=None):
self.watch("subject[C4Container].technology")
self.watch("subject[C4Container].description")
self.watch("subject[C4Container].type")
self.watch("children", self.update_shapes)

def update_shapes(self, event=None):
diagram = self.diagram
self.shape = Box(
Box(
text_name(self),
text_name(self),
CssNode(
"technology",
self.subject,
Text(
text=lambda: self.subject.technology
and f"[{diagram.gettext(self.subject.type)}: {self.subject.technology}]"
or f"[{diagram.gettext(self.subject.type)}]",
style={"font-size": "x-small"},
),
Text(
text=lambda: self.subject.description or "",
style={"padding": (4, 4, 0, 4)},
),
style={"padding": (20, 4, 4, 4)},
),
style={
"text-align": TextAlign.LEFT
if self.diagram and self.children
else TextAlign.CENTER,
"justify-content": JustifyContent.END
if self.diagram and self.children
else JustifyContent.CENTER,
},
*(
()
if self.children
else (
CssNode(
"description",
self.subject,
Text(text=lambda: self.subject.description or ""),
),
)
),
draw=draw_database,
)

Expand Down
24 changes: 9 additions & 15 deletions gaphor/C4Model/diagramitems/person.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from math import pi

from gaphor.C4Model import c4model
from gaphor.core.styling import JustifyContent, TextAlign
from gaphor.diagram.presentation import ElementPresentation, Named, text_name
from gaphor.diagram.shapes import Box, Text, stroke
from gaphor.diagram.shapes import Box, CssNode, Text, stroke
from gaphor.diagram.support import represents


Expand All @@ -17,26 +16,21 @@ def __init__(self, diagram, id=None):

def update_shapes(self, event=None):
self.shape = Box(
Box(
text_name(self),
text_name(self),
CssNode(
"technology",
self.subject,
Text(
text=lambda: f"[{self.diagram.gettext('Person')}]",
style={"font-size": "x-small"},
),
),
CssNode(
"description",
self.subject,
Text(
text=lambda: self.subject.description or "",
style={"padding": (4, 4, 0, 4)},
),
style={"padding": (4, 4, 4, 4)},
),
style={
"text-align": TextAlign.LEFT
if self.diagram and self.children
else TextAlign.CENTER,
"justify-content": JustifyContent.END
if self.diagram and self.children
else JustifyContent.CENTER,
},
draw=draw_person,
)

Expand Down
54 changes: 33 additions & 21 deletions gaphor/core/modeling/diagram.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
relation_one,
)
from gaphor.core.modeling.stylesheet import StyleSheet
from gaphor.core.styling import Style, StyleNode
from gaphor.core.styling import CompiledStyleSheet, Style, StyleNode
from gaphor.i18n import translation

log = logging.getLogger(__name__)
Expand All @@ -49,7 +49,6 @@
"color": (0, 0, 0, 1),
"font-family": "sans",
"font-size": 14,
"padding": (0, 0, 0, 0),
}


Expand Down Expand Up @@ -132,7 +131,7 @@ def __init__(
dark_mode: bool | None = None,
):
self.diagram = diagram
self.selection = selection or gaphas.selection.Selection()
self.selection = selection
self.pseudo: str | None = None
self.dark_mode = dark_mode

Expand Down Expand Up @@ -187,6 +186,17 @@ def __init__(
self.selection = selection
self.pseudo: str | None = None
self.dark_mode = dark_mode
self._state = (
(
"active" if item in selection.selected_items else "",
"focus" if item is selection.focused_item else "",
"hover" if item is selection.hovered_item else "",
"drop" if item is selection.dropzone_item else "",
"disabled" if item in selection.grayed_out_items else "",
)
if selection
else ()
)

def name(self) -> str:
return type(self.item).__name__.removesuffix("Item").lower()
Expand All @@ -200,12 +210,13 @@ def parent(self) -> StyleNode | None:
)

def children(self) -> Iterator[StyleNode]:
item = self.item
selection = self.selection
return (
yield from (
StyledItem(child, selection, dark_mode=self.dark_mode)
for child in self.item.children
for child in item.children
)
# TODO: Return css nodes in a Presentation (traverse shapes) to make :has() and :empty work
yield from (node.style_node(self) for node in item.css_nodes())

def attribute(self, name: str) -> str:
fields = name.split(".")
Expand All @@ -215,19 +226,7 @@ def attribute(self, name: str) -> str:
return a

def state(self) -> Sequence[str]:
item = self.item
selection = self.selection
return (
(
"active" if item in selection.selected_items else "",
"focus" if item is selection.focused_item else "",
"hover" if item is selection.hovered_item else "",
"drop" if item is selection.dropzone_item else "",
"disabled" if item in selection.grayed_out_items else "",
)
if selection
else ()
)
return self._state

def __hash__(self):
return hash((self.item, self.state(), self.dark_mode))
Expand Down Expand Up @@ -261,6 +260,7 @@ def __init__(self, id: Id | None = None, model: RepositoryProtocol | None = None
self._connections = gaphas.connections.Connections()
self._connections.add_handler(self._on_constraint_solved)

self._compiled_style_sheet: CompiledStyleSheet | None = None
self._registered_views: set[gaphas.model.View] = set()

self._watcher = self.watcher()
Expand Down Expand Up @@ -304,8 +304,17 @@ def styleSheet(self) -> StyleSheet | None:
return next(self.model.select(StyleSheet), None)

def style(self, node: StyleNode) -> Style:
style_sheet = self.styleSheet
return style_sheet.compute_style(node) if style_sheet else FALLBACK_STYLE
if not (compiled_style_sheet := self._compiled_style_sheet):
style_sheet = self.styleSheet
compiled_style_sheet = self._compiled_style_sheet = (
style_sheet.new_compiled_style_sheet() if style_sheet else None
)

return (
compiled_style_sheet.compute_style(node)
if compiled_style_sheet
else FALLBACK_STYLE
)

def gettext(self, message):
"""Translate a message to the language used in the model."""
Expand Down Expand Up @@ -411,6 +420,9 @@ def update_now(
) -> None:
"""Update the diagram canvas."""

# Clear our (cached) style sheet first
self._compiled_style_sheet = None

def dirty_items_with_ancestors():
for item in set(dirty_items):
yield item
Expand Down
3 changes: 3 additions & 0 deletions gaphor/core/modeling/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ def change_parent(self, new_parent):
m = m * new_parent.matrix_i2c.inverse()
self.matrix.set(*m)

def css_nodes(self):
return ()

def load(self, name, value):
if name == "matrix":
self.matrix.set(*literal_eval(value))
Expand Down
8 changes: 4 additions & 4 deletions gaphor/core/modeling/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from gaphor.core.modeling.element import Element
from gaphor.core.modeling.event import AttributeUpdated
from gaphor.core.modeling.properties import attribute
from gaphor.core.styling import CompiledStyleSheet, Style, StyleNode
from gaphor.core.styling import CompiledStyleSheet

SYSTEM_STYLE_SHEET = (importlib.resources.files("gaphor") / "diagram.css").read_text(
"utf-8"
Expand Down Expand Up @@ -44,12 +44,12 @@ def system_font_family(self, font_family: str):
def compile_style_sheet(self) -> None:
self._compiled_style_sheet = CompiledStyleSheet(
SYSTEM_STYLE_SHEET,
f"* {{ font-family: {self._system_font_family} }}",
f"diagram {{ font-family: {self._system_font_family} }}",
self.styleSheet,
)

def compute_style(self, node: StyleNode) -> Style:
return self._compiled_style_sheet.compute_style(node)
def new_compiled_style_sheet(self) -> CompiledStyleSheet:
return self._compiled_style_sheet.copy()

def postload(self):
super().postload()
Expand Down
Loading

0 comments on commit 24c39a6

Please sign in to comment.