diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf2a8f542..18c26b55a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,22 @@
# Change Log
-
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+
+## [0.6.0] - Unreleased
+
+### Added
+
+- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False
+- Added `Tree` widget which replaces `TreeControl`.
+
+### Changed
+
+- Rebuilt `DirectoryTree` with new `Tree` control.
+
## [0.5.0] - 2022-11-20
### Added
diff --git a/docs/api/directory_tree.md b/docs/api/directory_tree.md
new file mode 100644
index 000000000..f9d26e0e0
--- /dev/null
+++ b/docs/api/directory_tree.md
@@ -0,0 +1 @@
+::: textual.widgets.DirectoryTree
diff --git a/docs/api/tree.md b/docs/api/tree.md
new file mode 100644
index 000000000..73f20ee30
--- /dev/null
+++ b/docs/api/tree.md
@@ -0,0 +1 @@
+::: textual.widgets.Tree
diff --git a/docs/api/tree_node.md b/docs/api/tree_node.md
new file mode 100644
index 000000000..ad122443e
--- /dev/null
+++ b/docs/api/tree_node.md
@@ -0,0 +1 @@
+::: textual.widgets.TreeNode
diff --git a/docs/blog/images/placeholder-example.svg b/docs/blog/images/placeholder-example.svg
new file mode 100644
index 000000000..63ac40e97
--- /dev/null
+++ b/docs/blog/images/placeholder-example.svg
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PlaceholderApp
+
+
+
+
+
+
+
+
+
+
+ Placeholder p2 here!
+ This is a custom label for p1.
+ #p4
+ #p3 #p5 Placeholde
+ r
+
+ Lorem ipsum dolor sit
+ 26 x 6 amet, consectetur 27 x 6
+ adipiscing elit. Etiam
+ feugiat ac elit sit
+
+
+ Lorem ipsum dolor sit amet,
+ consectetur adipiscing elit. Etiam 40 x 6
+ feugiat ac elit sit amet accumsan.
+ Suspendisse bibendum nec libero quis
+ gravida. Phasellus id eleifend
+ ligula. Nullam imperdiet sem tellus,
+ sed vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,
+ Praesent iaculis tempor ultricies. ▆▆ consectetur adipiscing elit. Etiam ▆▆
+ Sed lacinia, tellus id rutrum feugiat ac elit sit amet accumsan.
+ lacinia, sapien sapien congue Suspendisse bibendum nec libero quis
+
+
+
+
diff --git a/docs/blog/posts/placeholder-pr.md b/docs/blog/posts/placeholder-pr.md
new file mode 100644
index 000000000..1208a97fe
--- /dev/null
+++ b/docs/blog/posts/placeholder-pr.md
@@ -0,0 +1,233 @@
+---
+draft: false
+date: 2022-11-22
+categories:
+ - DevLog
+authors:
+ - rodrigo
+---
+
+
+# What I learned from my first non-trivial PR
+
+
+--8<-- "docs/blog/images/placeholder-example.svg"
+
+
+It's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius.
+It is my second day at Textualize and I just got into the office.
+I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office.
+As I sit down, I turn myself in my chair to face my boss and colleagues to ask “So, what should I do today?”.
+I was not expecting Will's answer, but the challenge excited me:
+
+
+
+ > “I thought I'll just throw you in the deep end and have you write some code.”
+
+What happened next was that I spent two days [working on PR #1229](https://github.com/Textualize/textual/pull/1229) to add a new widget to the [Textual](https://github.com/Textualize/textual) code base.
+At the time of writing, the pull request has not been merged yet.
+Well, to be honest with you, it hasn't even been reviewed by anyone...
+But that won't stop me from blogging about some of the things I learned while creating this PR.
+
+
+## The placeholder widget
+
+This PR adds a widget called `Placeholder` to Textual.
+As per the documentation, this widget “is meant to have no complex functionality.
+Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.”
+
+The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready.
+The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget.
+
+As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up:
+
+
+--8<-- "docs/blog/images/placeholder-example.svg"
+
+
+The top left and top right widgets have custom labels.
+Immediately under the top right placeholder, you can see some placeholders identified as `#p3`, `#p4`, and `#p5`.
+Those are the IDs of the respective placeholders.
+Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text.
+
+
+## Bootstrapping the code for the widget
+
+So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company?
+The answer is simple: just copy and paste code!
+But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base.
+
+My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets.
+For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in [_button.py](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_button.py).
+By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works.
+
+For example, a widget can have a class attribute called `DEFAULT_CSS` that specifies the default CSS for that widget.
+I learned this just from staring at the code for the button widget.
+
+Studying the code base will also reveal the standards that are in place.
+For example, I learned that for a widget with variants (like the button with its “success” and “error” variants), the widget gets a CSS class with the name of the variant prefixed by a dash.
+You can learn this by looking at the method `Button.watch_variant`:
+
+```py
+class Button(Static, can_focus=True):
+ # ...
+
+ def watch_variant(self, old_variant: str, variant: str):
+ self.remove_class(f"-{old_variant}")
+ self.add_class(f"-{variant}")
+```
+
+In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed.
+
+
+## Handling the placeholder variant
+
+A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button.
+For the placeholder widget, we want the variant to determine what information the placeholder shows.
+The [original GitHub issue](https://github.com/Textualize/textual/issues/1200) mentions 5 variants for the placeholder:
+
+ - a variant that just shows a label or the placeholder ID;
+ - a variant that shows the size and location of the placeholder;
+ - a variant that shows the state of the placeholder (does it have focus? is the mouse over it?);
+ - a variant that shows the CSS that is applied to the placeholder itself; and
+ - a variant that shows some text inside the placeholder.
+
+The variant can be assigned when the placeholder is first instantiated, for example, `Placeholder("css")` would create a placeholder that shows its own CSS.
+However, we also want to have an `on_click` handler that cycles through all the possible variants.
+I was getting ready to reinvent the wheel when I remembered that the standard module [`itertools`](https://docs.python.org/3/library/itertools) has a lovely tool that does exactly what I needed!
+Thus, all I needed to do was create a new `cycle` through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked:
+
+```py
+class Placeholder(Static):
+ def __init__(
+ self,
+ variant: PlaceholderVariant = "default",
+ *,
+ label: str | None = None,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ ) -> None:
+ # ...
+
+ self.variant = self.validate_variant(variant)
+ # Set a cycle through the variants with the correct starting point.
+ self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
+ while next(self._variants_cycle) != self.variant:
+ pass
+
+ def on_click(self) -> None:
+ """Click handler to cycle through the placeholder variants."""
+ self.cycle_variant()
+
+ def cycle_variant(self) -> None:
+ """Get the next variant in the cycle."""
+ self.variant = next(self._variants_cycle)
+```
+
+I am just happy that I had the insight to add this little `while` loop when a placeholder is instantiated:
+
+```py
+from itertools import cycle
+# ...
+class Placeholder(Static):
+ # ...
+ def __init__(...):
+ # ...
+ self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
+ while next(self._variants_cycle) != self.variant:
+ pass
+```
+
+Can you see what would be wrong if this loop wasn't there?
+
+
+## Updating the render of the placeholder on variant change
+
+If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes.
+Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was...
+Defer the problem to another method:
+
+```py
+class Placeholder(Static):
+ # ...
+ variant = reactive("default")
+ # ...
+ def watch_variant(
+ self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
+ ) -> None:
+ self.validate_variant(variant)
+ self.remove_class(f"-{old_variant}")
+ self.add_class(f"-{variant}")
+ self.call_variant_update() # <-- let this method do the heavy lifting!
+```
+
+Doing this properly required some thinking.
+Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this.
+I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements:
+
+```py
+if variant == "default":
+ # render the default placeholder
+elif variant == "size":
+ # render the placeholder with its size
+elif variant == "state":
+ # render the state of the placeholder
+elif variant == "css":
+ # render the placeholder with its CSS rules
+elif variant == "text":
+ # render the placeholder with some text inside
+```
+
+However, I am a fan of using the built-in `getattr` and I thought of creating a rendering method for each different variant.
+Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call.
+This means that the method `Placeholder.call_variant_update` is just this:
+
+```py
+class Placeholder(Static):
+ # ...
+ def call_variant_update(self) -> None:
+ """Calls the appropriate method to update the render of the placeholder."""
+ update_variant_method = getattr(self, f"_update_{self.variant}_variant")
+ update_variant_method()
+```
+
+If `self.variant` is, say, `"size"`, then `update_variant_method` refers to `_update_size_variant`:
+
+```py
+class Placeholder(Static):
+ # ...
+ def _update_size_variant(self) -> None:
+ """Update the placeholder with the size of the placeholder."""
+ width, height = self.size
+ self._placeholder_label.update(f"[b]{width} x {height}[/b]")
+```
+
+This variant `"size"` also interacts with resizing events, so we have to watch out for those:
+
+```py
+class Placeholder(Static):
+ # ...
+ def on_resize(self, event: events.Resize) -> None:
+ """Update the placeholder "size" variant with the new placeholder size."""
+ if self.variant == "size":
+ self._update_size_variant()
+```
+
+
+## Deleting code is a (hurtful) blessing
+
+To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half.
+
+After careful consideration and after coming up with the `getattr` mechanism to update the display of the placeholder according to the active variant, I started showing the “final” product to Will and my other colleagues.
+Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state.
+This means that I had to **delete part of my code** even before it saw the light of day.
+
+On the one hand, deleting those chunks of code made me a bit sad.
+After all, I had spent quite some time thinking about how to best implement that functionality!
+But then, it was time to write documentation and tests, and I verified that the **best code** is the code that you don't even write!
+The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever!
+
+So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base.
+On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now.
+Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing!
diff --git a/docs/examples/widgets/directory_tree.py b/docs/examples/widgets/directory_tree.py
new file mode 100644
index 000000000..e0c14a92c
--- /dev/null
+++ b/docs/examples/widgets/directory_tree.py
@@ -0,0 +1,12 @@
+from textual.app import App, ComposeResult
+from textual.widgets import DirectoryTree
+
+
+class DirectoryTreeApp(App):
+ def compose(self) -> ComposeResult:
+ yield DirectoryTree("./")
+
+
+if __name__ == "__main__":
+ app = DirectoryTreeApp()
+ app.run()
diff --git a/docs/examples/widgets/tree.py b/docs/examples/widgets/tree.py
new file mode 100644
index 000000000..7b6ff27d7
--- /dev/null
+++ b/docs/examples/widgets/tree.py
@@ -0,0 +1,18 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Tree
+
+
+class TreeApp(App):
+ def compose(self) -> ComposeResult:
+ tree: Tree = Tree("Dune")
+ tree.root.expand()
+ characters = tree.root.add("Characters", expand=True)
+ characters.add_leaf("Paul")
+ characters.add_leaf("Jessica")
+ characters.add_leaf("Channi")
+ yield tree
+
+
+if __name__ == "__main__":
+ app = TreeApp()
+ app.run()
diff --git a/docs/widgets/directory_tree.md b/docs/widgets/directory_tree.md
new file mode 100644
index 000000000..2baf73e75
--- /dev/null
+++ b/docs/widgets/directory_tree.md
@@ -0,0 +1,43 @@
+# DirectoryTree
+
+A tree control to navigate the contents of your filesystem.
+
+- [x] Focusable
+- [ ] Container
+
+
+## Example
+
+The example below creates a simple tree to navigate the current working directory.
+
+```python
+--8<-- "docs/examples/widgets/directory_tree.py"
+```
+
+## Messages
+
+### FileSelected
+
+The `DirectoryTree.FileSelected` message is sent when the user selects a file in the tree
+
+- [x] Bubbles
+
+#### Attributes
+
+| attribute | type | purpose |
+| --------- | ----- | ----------------- |
+| `path` | `str` | Path of the file. |
+
+## Reactive Attributes
+
+| Name | Type | Default | Description |
+| ------------- | ------ | ------- | ----------------------------------------------- |
+| `show_root` | `bool` | `True` | Show the root node. |
+| `show_guides` | `bool` | `True` | Show guide lines between levels. |
+| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
+
+
+## See Also
+
+* [Tree][textual.widgets.DirectoryTree] code reference
+* [Tree][textual.widgets.Tree] code reference
diff --git a/docs/widgets/tree.md b/docs/widgets/tree.md
new file mode 100644
index 000000000..801d993f0
--- /dev/null
+++ b/docs/widgets/tree.md
@@ -0,0 +1,80 @@
+# Tree
+
+A tree control widget.
+
+- [x] Focusable
+- [ ] Container
+
+
+## Example
+
+The example below creates a simple tree.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/tree.py"}
+ ```
+
+=== "tree.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/tree.py"
+ ```
+
+A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels.
+
+
+## Reactive Attributes
+
+| Name | Type | Default | Description |
+| ------------- | ------ | ------- | ----------------------------------------------- |
+| `show_root` | `bool` | `True` | Show the root node. |
+| `show_guides` | `bool` | `True` | Show guide lines between levels. |
+| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
+
+
+
+## Messages
+
+### NodeSelected
+
+The `Tree.NodeSelected` message is sent when the user selects a tree node.
+
+
+#### Attributes
+
+| attribute | type | purpose |
+| --------- | ------------------------------------ | -------------- |
+| `node` | [TreeNode][textual.widgets.TreeNode] | Selected node. |
+
+
+### NodeExpanded
+
+The `Tree.NodeExpanded` message is sent when the user expands a node in the tree.
+
+#### Attributes
+
+| attribute | type | purpose |
+| --------- | ------------------------------------ | -------------- |
+| `node` | [TreeNode][textual.widgets.TreeNode] | Expanded node. |
+
+
+### NodeCollapsed
+
+
+The `Tree.NodeCollapsed` message is sent when the user expands a node in the tree.
+
+
+#### Attributes
+
+| attribute | type | purpose |
+| --------- | ------------------------------------ | --------------- |
+| `node` | [TreeNode][textual.widgets.TreeNode] | Collapsed node. |
+
+
+
+
+## See Also
+
+* [Tree][textual.widgets.Tree] code reference
+* [TreeNode][textual.widgets.TreeNode] code reference
diff --git a/docs/widgets/tree_control.md b/docs/widgets/tree_control.md
deleted file mode 100644
index 1155acfcc..000000000
--- a/docs/widgets/tree_control.md
+++ /dev/null
@@ -1 +0,0 @@
-# TreeControl
diff --git a/examples/code_browser.css b/examples/code_browser.css
index 2a58b68e1..9a2c295c9 100644
--- a/examples/code_browser.css
+++ b/examples/code_browser.css
@@ -3,22 +3,19 @@ Screen {
}
#tree-view {
- display: none;
+ display: none;
scrollbar-gutter: stable;
- width: auto;
+ overflow: auto;
+ width: auto;
+ height: 100%;
+ dock: left;
}
CodeBrowser.-show-tree #tree-view {
- display: block;
- dock: left;
- height: 100%;
+ display: block;
max-width: 50%;
- background: #151C25;
}
-DirectoryTree {
- padding-right: 1;
-}
#code-view {
overflow: auto scroll;
diff --git a/examples/code_browser.py b/examples/code_browser.py
index 678e8396a..215b85bcf 100644
--- a/examples/code_browser.py
+++ b/examples/code_browser.py
@@ -39,7 +39,7 @@ class CodeBrowser(App):
path = "./" if len(sys.argv) < 2 else sys.argv[1]
yield Header()
yield Container(
- Vertical(DirectoryTree(path), id="tree-view"),
+ DirectoryTree(path, id="tree-view"),
Vertical(Static(id="code", expand=True), id="code-view"),
)
yield Footer()
@@ -47,8 +47,11 @@ class CodeBrowser(App):
def on_mount(self, event: events.Mount) -> None:
self.query_one(DirectoryTree).focus()
- def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None:
+ def on_directory_tree_file_selected(
+ self, event: DirectoryTree.FileSelected
+ ) -> None:
"""Called when the user click a file in the directory tree."""
+ event.stop()
code_view = self.query_one("#code", Static)
try:
syntax = Syntax.from_path(
diff --git a/examples/food.json b/examples/food.json
new file mode 100644
index 000000000..d64d2f1ad
--- /dev/null
+++ b/examples/food.json
@@ -0,0 +1,1944 @@
+{
+ "code": "5060292302201",
+ "product": {
+ "_id": "5060292302201",
+ "_keywords": [
+ "snack",
+ "food",
+ "oil",
+ "anything",
+ "potato",
+ "appetizer",
+ "artificial",
+ "plant-based",
+ "cereal",
+ "and",
+ "in",
+ "popchip",
+ "preservative",
+ "barbeque",
+ "vegetarian",
+ "sunflower",
+ "chip",
+ "frie",
+ "potatoe",
+ "no",
+ "crisp",
+ "beverage",
+ "salty"
+ ],
+ "added_countries_tags": [],
+ "additives_debug_tags": [],
+ "additives_n": 2,
+ "additives_old_n": 2,
+ "additives_old_tags": [
+ "en:e330",
+ "en:e160c"
+ ],
+ "additives_original_tags": [
+ "en:e330",
+ "en:e160c"
+ ],
+ "additives_prev_original_tags": [
+ "en:e330",
+ "en:e160c"
+ ],
+ "additives_tags": [
+ "en:e160c",
+ "en:e330"
+ ],
+ "additives_tags_n": null,
+ "allergens": "en:milk",
+ "allergens_debug_tags": [],
+ "allergens_from_ingredients": "en:milk, milk",
+ "allergens_from_user": "(en) en:milk",
+ "allergens_hierarchy": [
+ "en:milk"
+ ],
+ "allergens_tags": [
+ "en:milk"
+ ],
+ "amino_acids_prev_tags": [],
+ "amino_acids_tags": [],
+ "brands": "Popchips",
+ "brands_debug_tags": [],
+ "brands_tags": [
+ "popchips"
+ ],
+ "carbon_footprint_from_known_ingredients_debug": "en:potato 54% x 0.6 = 32.4 g - ",
+ "carbon_footprint_percent_of_known_ingredients": 54,
+ "categories": "Plant-based foods and beverages, Plant-based foods, Snacks, Cereals and potatoes, Salty snacks, Appetizers, Chips and fries, Crisps, Potato crisps, Potato crisps in sunflower oil",
+ "categories_hierarchy": [
+ "en:plant-based-foods-and-beverages",
+ "en:plant-based-foods",
+ "en:snacks",
+ "en:cereals-and-potatoes",
+ "en:salty-snacks",
+ "en:appetizers",
+ "en:chips-and-fries",
+ "en:crisps",
+ "en:potato-crisps",
+ "en:potato-crisps-in-sunflower-oil"
+ ],
+ "categories_lc": "en",
+ "categories_old": "Plant-based foods and beverages, Plant-based foods, Snacks, Cereals and potatoes, Salty snacks, Appetizers, Chips and fries, Crisps, Potato crisps, Potato crisps in sunflower oil",
+ "categories_properties": {
+ "agribalyse_food_code:en": "4004",
+ "ciqual_food_code:en": "4004"
+ },
+ "categories_properties_tags": [
+ "all-products",
+ "categories-known",
+ "agribalyse-food-code-4004",
+ "agribalyse-food-code-known",
+ "agribalyse-proxy-food-code-unknown",
+ "ciqual-food-code-4004",
+ "ciqual-food-code-known",
+ "agribalyse-known",
+ "agribalyse-4004"
+ ],
+ "categories_tags": [
+ "en:plant-based-foods-and-beverages",
+ "en:plant-based-foods",
+ "en:snacks",
+ "en:cereals-and-potatoes",
+ "en:salty-snacks",
+ "en:appetizers",
+ "en:chips-and-fries",
+ "en:crisps",
+ "en:potato-crisps",
+ "en:potato-crisps-in-sunflower-oil"
+ ],
+ "category_properties": {
+ "ciqual_food_name:en": "Potato crisps",
+ "ciqual_food_name:fr": "Chips de pommes de terre, standard"
+ },
+ "checkers_tags": [],
+ "ciqual_food_name_tags": [
+ "potato-crisps"
+ ],
+ "cities_tags": [],
+ "code": "5060292302201",
+ "codes_tags": [
+ "code-13",
+ "5060292302xxx",
+ "506029230xxxx",
+ "50602923xxxxx",
+ "5060292xxxxxx",
+ "506029xxxxxxx",
+ "50602xxxxxxxx",
+ "5060xxxxxxxxx",
+ "506xxxxxxxxxx",
+ "50xxxxxxxxxxx",
+ "5xxxxxxxxxxxx"
+ ],
+ "compared_to_category": "en:potato-crisps-in-sunflower-oil",
+ "complete": 0,
+ "completeness": 0.8875,
+ "correctors_tags": [
+ "tacite",
+ "tacite-mass-editor",
+ "yuka.VjQwdU5yUUlpdmxUbjhWa3BFenc4ZGt1NDVLUFZtNm9NdWdOSWc9PQ",
+ "openfoodfacts-contributors",
+ "swipe-studio",
+ "yuka.sY2b0xO6T85zoF3NwEKvllZnctbb-gn-LDr4mHzUyem0FYPXMO5by7b5NKg",
+ "kiliweb",
+ "packbot",
+ "foodless",
+ "yuka.sY2b0xO6T85zoF3NwEKvlmBZVPXu-gnlBU3miFTQ-NeSIbDaMdUtu4fLGas"
+ ],
+ "countries": "France, United Kingdom",
+ "countries_debug_tags": [],
+ "countries_hierarchy": [
+ "en:france",
+ "en:united-kingdom"
+ ],
+ "countries_lc": "en",
+ "countries_tags": [
+ "en:france",
+ "en:united-kingdom"
+ ],
+ "created_t": 1433338177,
+ "creator": "kyzh",
+ "data_quality_bugs_tags": [],
+ "data_quality_errors_tags": [],
+ "data_quality_info_tags": [
+ "en:packaging-data-incomplete",
+ "en:ingredients-percent-analysis-ok",
+ "en:carbon-footprint-from-known-ingredients-but-not-from-meat-or-fish",
+ "en:ecoscore-extended-data-computed",
+ "en:ecoscore-extended-data-less-precise-than-agribalyse",
+ "en:food-groups-1-known",
+ "en:food-groups-2-known",
+ "en:food-groups-3-unknown"
+ ],
+ "data_quality_tags": [
+ "en:packaging-data-incomplete",
+ "en:ingredients-percent-analysis-ok",
+ "en:carbon-footprint-from-known-ingredients-but-not-from-meat-or-fish",
+ "en:ecoscore-extended-data-computed",
+ "en:ecoscore-extended-data-less-precise-than-agribalyse",
+ "en:food-groups-1-known",
+ "en:food-groups-2-known",
+ "en:food-groups-3-unknown",
+ "en:nutrition-value-very-low-for-category-energy",
+ "en:nutrition-value-very-low-for-category-fat",
+ "en:nutrition-value-very-low-for-category-carbohydrates",
+ "en:nutrition-value-very-high-for-category-sugars",
+ "en:ecoscore-origins-of-ingredients-origins-are-100-percent-unknown",
+ "en:ecoscore-production-system-no-label"
+ ],
+ "data_quality_warnings_tags": [
+ "en:nutrition-value-very-low-for-category-energy",
+ "en:nutrition-value-very-low-for-category-fat",
+ "en:nutrition-value-very-low-for-category-carbohydrates",
+ "en:nutrition-value-very-high-for-category-sugars",
+ "en:ecoscore-origins-of-ingredients-origins-are-100-percent-unknown",
+ "en:ecoscore-production-system-no-label"
+ ],
+ "data_sources": "App - yuka, Apps, App - Horizon",
+ "data_sources_tags": [
+ "app-yuka",
+ "apps",
+ "app-horizon"
+ ],
+ "debug_param_sorted_langs": [
+ "en",
+ "fr"
+ ],
+ "ecoscore_data": {
+ "adjustments": {
+ "origins_of_ingredients": {
+ "aggregated_origins": [
+ {
+ "origin": "en:unknown",
+ "percent": 100
+ }
+ ],
+ "epi_score": 0,
+ "epi_value": -5,
+ "origins_from_origins_field": [
+ "en:unknown"
+ ],
+ "transportation_scores": {
+ "ad": 0,
+ "al": 0,
+ "at": 0,
+ "ax": 0,
+ "ba": 0,
+ "be": 0,
+ "bg": 0,
+ "ch": 0,
+ "cy": 0,
+ "cz": 0,
+ "de": 0,
+ "dk": 0,
+ "dz": 0,
+ "ee": 0,
+ "eg": 0,
+ "es": 0,
+ "fi": 0,
+ "fo": 0,
+ "fr": 0,
+ "gg": 0,
+ "gi": 0,
+ "gr": 0,
+ "hr": 0,
+ "hu": 0,
+ "ie": 0,
+ "il": 0,
+ "im": 0,
+ "is": 0,
+ "it": 0,
+ "je": 0,
+ "lb": 0,
+ "li": 0,
+ "lt": 0,
+ "lu": 0,
+ "lv": 0,
+ "ly": 0,
+ "ma": 0,
+ "mc": 0,
+ "md": 0,
+ "me": 0,
+ "mk": 0,
+ "mt": 0,
+ "nl": 0,
+ "no": 0,
+ "pl": 0,
+ "ps": 0,
+ "pt": 0,
+ "ro": 0,
+ "rs": 0,
+ "se": 0,
+ "si": 0,
+ "sj": 0,
+ "sk": 0,
+ "sm": 0,
+ "sy": 0,
+ "tn": 0,
+ "tr": 0,
+ "ua": 0,
+ "uk": 0,
+ "us": 0,
+ "va": 0,
+ "world": 0,
+ "xk": 0
+ },
+ "transportation_values": {
+ "ad": 0,
+ "al": 0,
+ "at": 0,
+ "ax": 0,
+ "ba": 0,
+ "be": 0,
+ "bg": 0,
+ "ch": 0,
+ "cy": 0,
+ "cz": 0,
+ "de": 0,
+ "dk": 0,
+ "dz": 0,
+ "ee": 0,
+ "eg": 0,
+ "es": 0,
+ "fi": 0,
+ "fo": 0,
+ "fr": 0,
+ "gg": 0,
+ "gi": 0,
+ "gr": 0,
+ "hr": 0,
+ "hu": 0,
+ "ie": 0,
+ "il": 0,
+ "im": 0,
+ "is": 0,
+ "it": 0,
+ "je": 0,
+ "lb": 0,
+ "li": 0,
+ "lt": 0,
+ "lu": 0,
+ "lv": 0,
+ "ly": 0,
+ "ma": 0,
+ "mc": 0,
+ "md": 0,
+ "me": 0,
+ "mk": 0,
+ "mt": 0,
+ "nl": 0,
+ "no": 0,
+ "pl": 0,
+ "ps": 0,
+ "pt": 0,
+ "ro": 0,
+ "rs": 0,
+ "se": 0,
+ "si": 0,
+ "sj": 0,
+ "sk": 0,
+ "sm": 0,
+ "sy": 0,
+ "tn": 0,
+ "tr": 0,
+ "ua": 0,
+ "uk": 0,
+ "us": 0,
+ "va": 0,
+ "world": 0,
+ "xk": 0
+ },
+ "values": {
+ "ad": -5,
+ "al": -5,
+ "at": -5,
+ "ax": -5,
+ "ba": -5,
+ "be": -5,
+ "bg": -5,
+ "ch": -5,
+ "cy": -5,
+ "cz": -5,
+ "de": -5,
+ "dk": -5,
+ "dz": -5,
+ "ee": -5,
+ "eg": -5,
+ "es": -5,
+ "fi": -5,
+ "fo": -5,
+ "fr": -5,
+ "gg": -5,
+ "gi": -5,
+ "gr": -5,
+ "hr": -5,
+ "hu": -5,
+ "ie": -5,
+ "il": -5,
+ "im": -5,
+ "is": -5,
+ "it": -5,
+ "je": -5,
+ "lb": -5,
+ "li": -5,
+ "lt": -5,
+ "lu": -5,
+ "lv": -5,
+ "ly": -5,
+ "ma": -5,
+ "mc": -5,
+ "md": -5,
+ "me": -5,
+ "mk": -5,
+ "mt": -5,
+ "nl": -5,
+ "no": -5,
+ "pl": -5,
+ "ps": -5,
+ "pt": -5,
+ "ro": -5,
+ "rs": -5,
+ "se": -5,
+ "si": -5,
+ "sj": -5,
+ "sk": -5,
+ "sm": -5,
+ "sy": -5,
+ "tn": -5,
+ "tr": -5,
+ "ua": -5,
+ "uk": -5,
+ "us": -5,
+ "va": -5,
+ "world": -5,
+ "xk": -5
+ },
+ "warning": "origins_are_100_percent_unknown"
+ },
+ "packaging": {
+ "non_recyclable_and_non_biodegradable_materials": 1,
+ "packagings": [
+ {
+ "ecoscore_material_score": 0,
+ "ecoscore_shape_ratio": 1,
+ "material": "en:plastic",
+ "non_recyclable_and_non_biodegradable": "maybe",
+ "shape": "en:pack"
+ }
+ ],
+ "score": 0,
+ "value": -10
+ },
+ "production_system": {
+ "labels": [],
+ "value": 0,
+ "warning": "no_label"
+ },
+ "threatened_species": {}
+ },
+ "agribalyse": {
+ "agribalyse_food_code": "4004",
+ "co2_agriculture": 1.2992636,
+ "co2_consumption": 0,
+ "co2_distribution": 0.029120657,
+ "co2_packaging": 0.28581962,
+ "co2_processing": 0.39294234,
+ "co2_total": 2.2443641,
+ "co2_transportation": 0.23728203,
+ "code": "4004",
+ "dqr": "2.45",
+ "ef_agriculture": 0.18214682,
+ "ef_consumption": 0,
+ "ef_distribution": 0.0098990521,
+ "ef_packaging": 0.021558384,
+ "ef_processing": 0.057508389,
+ "ef_total": 0.29200269,
+ "ef_transportation": 0.020894187,
+ "is_beverage": 0,
+ "name_en": "Potato crisps",
+ "name_fr": "Chips de pommes de terre, standard",
+ "score": 78
+ },
+ "grade": "b",
+ "grades": {
+ "ad": "b",
+ "al": "b",
+ "at": "b",
+ "ax": "b",
+ "ba": "b",
+ "be": "b",
+ "bg": "b",
+ "ch": "b",
+ "cy": "b",
+ "cz": "b",
+ "de": "b",
+ "dk": "b",
+ "dz": "b",
+ "ee": "b",
+ "eg": "b",
+ "es": "b",
+ "fi": "b",
+ "fo": "b",
+ "fr": "b",
+ "gg": "b",
+ "gi": "b",
+ "gr": "b",
+ "hr": "b",
+ "hu": "b",
+ "ie": "b",
+ "il": "b",
+ "im": "b",
+ "is": "b",
+ "it": "b",
+ "je": "b",
+ "lb": "b",
+ "li": "b",
+ "lt": "b",
+ "lu": "b",
+ "lv": "b",
+ "ly": "b",
+ "ma": "b",
+ "mc": "b",
+ "md": "b",
+ "me": "b",
+ "mk": "b",
+ "mt": "b",
+ "nl": "b",
+ "no": "b",
+ "pl": "b",
+ "ps": "b",
+ "pt": "b",
+ "ro": "b",
+ "rs": "b",
+ "se": "b",
+ "si": "b",
+ "sj": "b",
+ "sk": "b",
+ "sm": "b",
+ "sy": "b",
+ "tn": "b",
+ "tr": "b",
+ "ua": "b",
+ "uk": "b",
+ "us": "b",
+ "va": "b",
+ "world": "b",
+ "xk": "b"
+ },
+ "missing": {
+ "labels": 1,
+ "origins": 1
+ },
+ "missing_data_warning": 1,
+ "score": 63,
+ "scores": {
+ "ad": 63,
+ "al": 63,
+ "at": 63,
+ "ax": 63,
+ "ba": 63,
+ "be": 63,
+ "bg": 63,
+ "ch": 63,
+ "cy": 63,
+ "cz": 63,
+ "de": 63,
+ "dk": 63,
+ "dz": 63,
+ "ee": 63,
+ "eg": 63,
+ "es": 63,
+ "fi": 63,
+ "fo": 63,
+ "fr": 63,
+ "gg": 63,
+ "gi": 63,
+ "gr": 63,
+ "hr": 63,
+ "hu": 63,
+ "ie": 63,
+ "il": 63,
+ "im": 63,
+ "is": 63,
+ "it": 63,
+ "je": 63,
+ "lb": 63,
+ "li": 63,
+ "lt": 63,
+ "lu": 63,
+ "lv": 63,
+ "ly": 63,
+ "ma": 63,
+ "mc": 63,
+ "md": 63,
+ "me": 63,
+ "mk": 63,
+ "mt": 63,
+ "nl": 63,
+ "no": 63,
+ "pl": 63,
+ "ps": 63,
+ "pt": 63,
+ "ro": 63,
+ "rs": 63,
+ "se": 63,
+ "si": 63,
+ "sj": 63,
+ "sk": 63,
+ "sm": 63,
+ "sy": 63,
+ "tn": 63,
+ "tr": 63,
+ "ua": 63,
+ "uk": 63,
+ "us": 63,
+ "va": 63,
+ "world": 63,
+ "xk": 63
+ },
+ "status": "known"
+ },
+ "ecoscore_extended_data": {
+ "impact": {
+ "ef_single_score_log_stddev": 0.0664290643574977,
+ "likeliest_impacts": {
+ "Climate_change": 0.0835225930657116,
+ "EF_single_score": 0.0132996566234689
+ },
+ "likeliest_recipe": {
+ "en:Oak_smoked_sea_salti_yeast_extract": 0.103505496656251,
+ "en:e160c": 0.10350549665625,
+ "en:e330": 0.10350549665625,
+ "en:flavouring": 0.10350549665625,
+ "en:garlic_powder": 0.103505496656251,
+ "en:milk": 1.55847864453775,
+ "en:onion": 0.15510736429208,
+ "en:potato": 69.2208020730349,
+ "en:potato_starch": 10.5320407294931,
+ "en:rice_flour": 13.8595510001351,
+ "en:salt": 1.3345917157533,
+ "en:spice": 0.10350549665625,
+ "en:sugar": 10.2883618334396,
+ "en:sunflower_oil": 14.1645835312727,
+ "en:tomato_powder": 0.10350549665625,
+ "en:water": 6.24510964041154,
+ "en:yeast_powder": 0.103505496656251
+ },
+ "mass_ratio_uncharacterized": 0.0244618467395455,
+ "uncharacterized_ingredients": {
+ "impact": [
+ "en:yeast-powder",
+ "en:flavouring",
+ "en:Oak smoked sea salti yeast extract",
+ "en:e160c",
+ "en:e330"
+ ],
+ "nutrition": [
+ "en:flavouring",
+ "en:Oak smoked sea salti yeast extract"
+ ]
+ },
+ "uncharacterized_ingredients_mass_proportion": {
+ "impact": 0.0244618467395455,
+ "nutrition": 0.0106506947223728
+ },
+ "uncharacterized_ingredients_ratio": {
+ "impact": 0.3125,
+ "nutrition": 0.125
+ },
+ "warnings": [
+ "Fermentation agents are present in the product (en:yeast-powder). Carbohydrates and sugars mass balance will not be considered to estimate potential recipes",
+ "The product has a high number of impact uncharacterized ingredients: 31%"
+ ]
+ }
+ },
+ "ecoscore_extended_data_version": "4",
+ "ecoscore_grade": "b",
+ "ecoscore_score": 63,
+ "ecoscore_tags": [
+ "b"
+ ],
+ "editors": [
+ "kyzh",
+ "tacite"
+ ],
+ "editors_tags": [
+ "yuka.VjQwdU5yUUlpdmxUbjhWa3BFenc4ZGt1NDVLUFZtNm9NdWdOSWc9PQ",
+ "yuka.sY2b0xO6T85zoF3NwEKvllZnctbb-gn-LDr4mHzUyem0FYPXMO5by7b5NKg",
+ "tacite",
+ "kyzh",
+ "foodless",
+ "packbot",
+ "openfoodfacts-contributors",
+ "kiliweb",
+ "yuka.sY2b0xO6T85zoF3NwEKvlmBZVPXu-gnlBU3miFTQ-NeSIbDaMdUtu4fLGas",
+ "ecoscore-impact-estimator",
+ "swipe-studio",
+ "tacite-mass-editor"
+ ],
+ "emb_codes": "",
+ "emb_codes_20141016": "",
+ "emb_codes_debug_tags": [],
+ "emb_codes_orig": "",
+ "emb_codes_tags": [],
+ "entry_dates_tags": [
+ "2015-06-03",
+ "2015-06",
+ "2015"
+ ],
+ "expiration_date": "11/05/2016",
+ "expiration_date_debug_tags": [],
+ "food_groups": "en:appetizers",
+ "food_groups_tags": [
+ "en:salty-snacks",
+ "en:appetizers"
+ ],
+ "fruits-vegetables-nuts_100g_estimate": 0,
+ "generic_name": "",
+ "generic_name_en": "",
+ "generic_name_en_debug_tags": [],
+ "generic_name_fr": "",
+ "generic_name_fr_debug_tags": [],
+ "id": "5060292302201",
+ "image_front_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.200.jpg",
+ "image_front_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.100.jpg",
+ "image_front_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.400.jpg",
+ "image_ingredients_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.200.jpg",
+ "image_ingredients_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.100.jpg",
+ "image_ingredients_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.400.jpg",
+ "image_nutrition_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.200.jpg",
+ "image_nutrition_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.100.jpg",
+ "image_nutrition_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.400.jpg",
+ "image_small_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.200.jpg",
+ "image_thumb_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.100.jpg",
+ "image_url": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.400.jpg",
+ "images": {
+ "1": {
+ "sizes": {
+ "100": {
+ "h": 74,
+ "w": 100
+ },
+ "400": {
+ "h": 296,
+ "w": 400
+ },
+ "full": {
+ "h": 1482,
+ "w": 2000
+ }
+ },
+ "uploaded_t": 1433338177,
+ "uploader": "kyzh"
+ },
+ "2": {
+ "sizes": {
+ "100": {
+ "h": 74,
+ "w": 100
+ },
+ "400": {
+ "h": 296,
+ "w": 400
+ },
+ "full": {
+ "h": 1482,
+ "w": 2000
+ }
+ },
+ "uploaded_t": 1433338194,
+ "uploader": "kyzh"
+ },
+ "3": {
+ "sizes": {
+ "100": {
+ "h": 74,
+ "w": 100
+ },
+ "400": {
+ "h": 296,
+ "w": 400
+ },
+ "full": {
+ "h": 1482,
+ "w": 2000
+ }
+ },
+ "uploaded_t": 1433338203,
+ "uploader": "kyzh"
+ },
+ "4": {
+ "sizes": {
+ "100": {
+ "h": 74,
+ "w": 100
+ },
+ "400": {
+ "h": 296,
+ "w": 400
+ },
+ "full": {
+ "h": 1482,
+ "w": 2000
+ }
+ },
+ "uploaded_t": 1433338215,
+ "uploader": "kyzh"
+ },
+ "5": {
+ "sizes": {
+ "100": {
+ "h": 74,
+ "w": 100
+ },
+ "400": {
+ "h": 296,
+ "w": 400
+ },
+ "full": {
+ "h": 1482,
+ "w": 2000
+ }
+ },
+ "uploaded_t": 1433338229,
+ "uploader": "kyzh"
+ },
+ "6": {
+ "sizes": {
+ "100": {
+ "h": 74,
+ "w": 100
+ },
+ "400": {
+ "h": 296,
+ "w": 400
+ },
+ "full": {
+ "h": 1482,
+ "w": 2000
+ }
+ },
+ "uploaded_t": 1433338245,
+ "uploader": "kyzh"
+ },
+ "7": {
+ "sizes": {
+ "100": {
+ "h": 43,
+ "w": 100
+ },
+ "400": {
+ "h": 171,
+ "w": 400
+ },
+ "full": {
+ "h": 846,
+ "w": 1974
+ }
+ },
+ "uploaded_t": "1508236270",
+ "uploader": "kiliweb"
+ },
+ "8": {
+ "sizes": {
+ "100": {
+ "h": 100,
+ "w": 82
+ },
+ "400": {
+ "h": 400,
+ "w": 326
+ },
+ "full": {
+ "h": 1140,
+ "w": 930
+ }
+ },
+ "uploaded_t": 1620505759,
+ "uploader": "kiliweb"
+ },
+ "9": {
+ "sizes": {
+ "100": {
+ "h": 56,
+ "w": 100
+ },
+ "400": {
+ "h": 225,
+ "w": 400
+ },
+ "full": {
+ "h": 569,
+ "w": 1011
+ }
+ },
+ "uploaded_t": 1656075071,
+ "uploader": "kiliweb"
+ },
+ "front": {
+ "geometry": "1421x1825-0-95",
+ "imgid": "1",
+ "normalize": "false",
+ "rev": "9",
+ "sizes": {
+ "100": {
+ "h": 100,
+ "w": 78
+ },
+ "200": {
+ "h": 200,
+ "w": 156
+ },
+ "400": {
+ "h": 400,
+ "w": 311
+ },
+ "full": {
+ "h": 1825,
+ "w": 1421
+ }
+ },
+ "white_magic": "true"
+ },
+ "front_en": {
+ "angle": 0,
+ "coordinates_image_size": "full",
+ "geometry": "0x0--1--1",
+ "imgid": "8",
+ "normalize": null,
+ "rev": "23",
+ "sizes": {
+ "100": {
+ "h": 100,
+ "w": 82
+ },
+ "200": {
+ "h": 200,
+ "w": 163
+ },
+ "400": {
+ "h": 400,
+ "w": 326
+ },
+ "full": {
+ "h": 1140,
+ "w": 930
+ }
+ },
+ "white_magic": null,
+ "x1": "-1",
+ "x2": "-1",
+ "y1": "-1",
+ "y2": "-1"
+ },
+ "ingredients": {
+ "geometry": "1730x526-125-304",
+ "imgid": "5",
+ "normalize": "false",
+ "ocr": 1,
+ "orientation": "0",
+ "rev": "11",
+ "sizes": {
+ "100": {
+ "h": 30,
+ "w": 100
+ },
+ "200": {
+ "h": 61,
+ "w": 200
+ },
+ "400": {
+ "h": 122,
+ "w": 400
+ },
+ "full": {
+ "h": 526,
+ "w": 1730
+ }
+ },
+ "white_magic": "false"
+ },
+ "ingredients_en": {
+ "geometry": "1730x526-125-304",
+ "imgid": "5",
+ "normalize": "false",
+ "ocr": 1,
+ "orientation": "0",
+ "rev": "11",
+ "sizes": {
+ "100": {
+ "h": 30,
+ "w": 100
+ },
+ "200": {
+ "h": 61,
+ "w": 200
+ },
+ "400": {
+ "h": 122,
+ "w": 400
+ },
+ "full": {
+ "h": 526,
+ "w": 1730
+ }
+ },
+ "white_magic": "false"
+ },
+ "nutrition": {
+ "geometry": "1131x920-150-794",
+ "imgid": "3",
+ "normalize": "false",
+ "ocr": 1,
+ "orientation": "0",
+ "rev": "10",
+ "sizes": {
+ "100": {
+ "h": 81,
+ "w": 100
+ },
+ "200": {
+ "h": 163,
+ "w": 200
+ },
+ "400": {
+ "h": 325,
+ "w": 400
+ },
+ "full": {
+ "h": 920,
+ "w": 1131
+ }
+ },
+ "white_magic": "false"
+ },
+ "nutrition_en": {
+ "angle": 0,
+ "coordinates_image_size": "full",
+ "geometry": "0x0--1--1",
+ "imgid": "9",
+ "normalize": null,
+ "rev": "32",
+ "sizes": {
+ "100": {
+ "h": 56,
+ "w": 100
+ },
+ "200": {
+ "h": 113,
+ "w": 200
+ },
+ "400": {
+ "h": 225,
+ "w": 400
+ },
+ "full": {
+ "h": 569,
+ "w": 1011
+ }
+ },
+ "white_magic": null,
+ "x1": "-1",
+ "x2": "-1",
+ "y1": "-1",
+ "y2": "-1"
+ }
+ },
+ "informers_tags": [
+ "kyzh",
+ "tacite",
+ "tacite-mass-editor",
+ "yuka.VjQwdU5yUUlpdmxUbjhWa3BFenc4ZGt1NDVLUFZtNm9NdWdOSWc9PQ",
+ "openfoodfacts-contributors"
+ ],
+ "ingredients": [
+ {
+ "id": "en:potato",
+ "percent": 54,
+ "percent_estimate": 54,
+ "percent_max": 54,
+ "percent_min": 54,
+ "processing": "en:dried",
+ "rank": 1,
+ "text": "potatoes",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "from_palm_oil": "no",
+ "id": "en:sunflower-oil",
+ "percent_estimate": 28.75,
+ "percent_max": 46,
+ "percent_min": 11.5,
+ "rank": 2,
+ "text": "sunflower oil",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "has_sub_ingredients": "yes",
+ "id": "en:coating",
+ "percent_estimate": 8.625,
+ "percent_max": 33.3333333333333,
+ "percent_min": 0,
+ "rank": 3,
+ "text": "seasoning",
+ "vegan": "ignore",
+ "vegetarian": "ignore"
+ },
+ {
+ "id": "en:rice-flour",
+ "percent_estimate": 4.3125,
+ "percent_max": 17.25,
+ "percent_min": 0,
+ "rank": 4,
+ "text": "rice flour",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:potato-starch",
+ "percent_estimate": 4.3125,
+ "percent_max": 11.5,
+ "percent_min": 0,
+ "rank": 5,
+ "text": "potato starch",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:sugar",
+ "percent_estimate": 4.3125,
+ "percent_max": 33.3333333333333,
+ "percent_min": 0,
+ "text": "sugar",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "has_sub_ingredients": "yes",
+ "id": "en:whey-powder",
+ "percent_estimate": 2.15625,
+ "percent_max": 16.6666666666667,
+ "percent_min": 0,
+ "text": "whey powder",
+ "vegan": "no",
+ "vegetarian": "maybe"
+ },
+ {
+ "id": "en:salt",
+ "percent_estimate": 1.078125,
+ "percent_max": 11.1111111111111,
+ "percent_min": 0,
+ "text": "salt",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:onion",
+ "percent_estimate": 0.5390625,
+ "percent_max": 8.33333333333333,
+ "percent_min": 0,
+ "processing": "en:powder",
+ "text": "onion",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:yeast-powder",
+ "percent_estimate": 0.26953125,
+ "percent_max": 6.66666666666667,
+ "percent_min": 0,
+ "text": "yeast powder",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:garlic-powder",
+ "percent_estimate": 0.134765625,
+ "percent_max": 5.55555555555556,
+ "percent_min": 0,
+ "text": "garlic powder",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:tomato-powder",
+ "percent_estimate": 0.0673828125,
+ "percent_max": 4.76190476190476,
+ "percent_min": 0,
+ "text": "tomato powder",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:oak-smoked-sea-salti-yeast-extract",
+ "percent_estimate": 0.03369140625,
+ "percent_max": 4.16666666666667,
+ "percent_min": 0,
+ "text": "Oak smoked sea salti yeast extract"
+ },
+ {
+ "id": "en:flavouring",
+ "percent_estimate": 0.016845703125,
+ "percent_max": 3.7037037037037,
+ "percent_min": 0,
+ "text": "flavourings",
+ "vegan": "maybe",
+ "vegetarian": "maybe"
+ },
+ {
+ "id": "en:spice",
+ "percent_estimate": 0.0084228515625,
+ "percent_max": 3.33333333333333,
+ "percent_min": 0,
+ "text": "spices",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "has_sub_ingredients": "yes",
+ "id": "en:acid",
+ "percent_estimate": 0.00421142578125,
+ "percent_max": 3.03030303030303,
+ "percent_min": 0,
+ "text": "acid"
+ },
+ {
+ "has_sub_ingredients": "yes",
+ "id": "en:colour",
+ "percent_estimate": 0.00421142578125,
+ "percent_max": 2.77777777777778,
+ "percent_min": 0,
+ "text": "colour"
+ },
+ {
+ "id": "en:milk",
+ "percent_estimate": 2.15625,
+ "percent_max": 16.6666666666667,
+ "percent_min": 0,
+ "text": "milk",
+ "vegan": "no",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:e330",
+ "percent_estimate": 0.00421142578125,
+ "percent_max": 3.03030303030303,
+ "percent_min": 0,
+ "text": "citric acid",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ },
+ {
+ "id": "en:e160c",
+ "percent_estimate": 0.00421142578125,
+ "percent_max": 2.77777777777778,
+ "percent_min": 0,
+ "text": "paprika extract",
+ "vegan": "yes",
+ "vegetarian": "yes"
+ }
+ ],
+ "ingredients_analysis": {
+ "en:non-vegan": [
+ "en:whey-powder",
+ "en:milk"
+ ],
+ "en:palm-oil-content-unknown": [
+ "en:oak-smoked-sea-salti-yeast-extract"
+ ],
+ "en:vegan-status-unknown": [
+ "en:oak-smoked-sea-salti-yeast-extract"
+ ],
+ "en:vegetarian-status-unknown": [
+ "en:oak-smoked-sea-salti-yeast-extract"
+ ]
+ },
+ "ingredients_analysis_tags": [
+ "en:palm-oil-free",
+ "en:non-vegan",
+ "en:vegetarian"
+ ],
+ "ingredients_debug": [
+ "54% dried potatoes",
+ ",",
+ null,
+ null,
+ null,
+ " sunflower oil",
+ ",",
+ null,
+ null,
+ null,
+ " seasoning ",
+ "(",
+ "(",
+ null,
+ null,
+ "sugar",
+ ",",
+ null,
+ null,
+ null,
+ " whey powder ",
+ "[",
+ "[",
+ null,
+ null,
+ "milk]",
+ ",",
+ null,
+ null,
+ null,
+ " salt",
+ ",",
+ null,
+ null,
+ null,
+ " onion powder",
+ ",",
+ null,
+ null,
+ null,
+ " yeast powder",
+ ",",
+ null,
+ null,
+ null,
+ " garlic powder",
+ ",",
+ null,
+ null,
+ null,
+ " tomato powder",
+ ",",
+ null,
+ null,
+ null,
+ " Oak smoked sea salti yeast extract",
+ ",",
+ null,
+ null,
+ null,
+ " flavourings",
+ ",",
+ null,
+ null,
+ null,
+ " spices",
+ ",",
+ null,
+ null,
+ null,
+ " acid",
+ ":",
+ ":",
+ null,
+ null,
+ " citric acid",
+ ",",
+ null,
+ null,
+ null,
+ " colour",
+ ":",
+ ":",
+ null,
+ null,
+ " paprika extract)",
+ ",",
+ null,
+ null,
+ null,
+ " rice flour",
+ ",",
+ null,
+ null,
+ null,
+ " potato starch."
+ ],
+ "ingredients_from_or_that_may_be_from_palm_oil_n": 0,
+ "ingredients_from_palm_oil_n": 0,
+ "ingredients_from_palm_oil_tags": [],
+ "ingredients_hierarchy": [
+ "en:potato",
+ "en:vegetable",
+ "en:root-vegetable",
+ "en:sunflower-oil",
+ "en:oil-and-fat",
+ "en:vegetable-oil-and-fat",
+ "en:vegetable-oil",
+ "en:coating",
+ "en:rice-flour",
+ "en:flour",
+ "en:rice",
+ "en:potato-starch",
+ "en:starch",
+ "en:sugar",
+ "en:added-sugar",
+ "en:disaccharide",
+ "en:whey-powder",
+ "en:dairy",
+ "en:whey",
+ "en:salt",
+ "en:onion",
+ "en:yeast-powder",
+ "en:yeast",
+ "en:garlic-powder",
+ "en:garlic",
+ "en:tomato-powder",
+ "en:tomato",
+ "en:oak-smoked-sea-salti-yeast-extract",
+ "en:flavouring",
+ "en:spice",
+ "en:condiment",
+ "en:acid",
+ "en:colour",
+ "en:milk",
+ "en:e330",
+ "en:e160c"
+ ],
+ "ingredients_ids_debug": [
+ "54-dried-potatoes",
+ "sunflower-oil",
+ "seasoning",
+ "sugar",
+ "whey-powder",
+ "milk",
+ "salt",
+ "onion-powder",
+ "yeast-powder",
+ "garlic-powder",
+ "tomato-powder",
+ "oak-smoked-sea-salti-yeast-extract",
+ "flavourings",
+ "spices",
+ "acid",
+ "citric-acid",
+ "colour",
+ "paprika-extract",
+ "rice-flour",
+ "potato-starch"
+ ],
+ "ingredients_n": 20,
+ "ingredients_n_tags": [
+ "20",
+ "11-20"
+ ],
+ "ingredients_original_tags": [
+ "en:potato",
+ "en:sunflower-oil",
+ "en:coating",
+ "en:rice-flour",
+ "en:potato-starch",
+ "en:sugar",
+ "en:whey-powder",
+ "en:salt",
+ "en:onion",
+ "en:yeast-powder",
+ "en:garlic-powder",
+ "en:tomato-powder",
+ "en:oak-smoked-sea-salti-yeast-extract",
+ "en:flavouring",
+ "en:spice",
+ "en:acid",
+ "en:colour",
+ "en:milk",
+ "en:e330",
+ "en:e160c"
+ ],
+ "ingredients_percent_analysis": 1,
+ "ingredients_tags": [
+ "en:potato",
+ "en:vegetable",
+ "en:root-vegetable",
+ "en:sunflower-oil",
+ "en:oil-and-fat",
+ "en:vegetable-oil-and-fat",
+ "en:vegetable-oil",
+ "en:coating",
+ "en:rice-flour",
+ "en:flour",
+ "en:rice",
+ "en:potato-starch",
+ "en:starch",
+ "en:sugar",
+ "en:added-sugar",
+ "en:disaccharide",
+ "en:whey-powder",
+ "en:dairy",
+ "en:whey",
+ "en:salt",
+ "en:onion",
+ "en:yeast-powder",
+ "en:yeast",
+ "en:garlic-powder",
+ "en:garlic",
+ "en:tomato-powder",
+ "en:tomato",
+ "en:oak-smoked-sea-salti-yeast-extract",
+ "en:flavouring",
+ "en:spice",
+ "en:condiment",
+ "en:acid",
+ "en:colour",
+ "en:milk",
+ "en:e330",
+ "en:e160c"
+ ],
+ "ingredients_text": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.",
+ "ingredients_text_debug": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.",
+ "ingredients_text_debug_tags": [],
+ "ingredients_text_en": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.",
+ "ingredients_text_fr": "",
+ "ingredients_text_fr_debug_tags": [],
+ "ingredients_text_with_allergens": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk ], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.",
+ "ingredients_text_with_allergens_en": "54% dried potatoes, sunflower oil, seasoning (sugar, whey powder [milk ], salt, onion powder, yeast powder, garlic powder, tomato powder, Oak smoked sea salti yeast extract, flavourings, spices, acid: citric acid, colour: paprika extract), rice flour, potato starch.",
+ "ingredients_that_may_be_from_palm_oil_n": 0,
+ "ingredients_that_may_be_from_palm_oil_tags": [],
+ "ingredients_with_specified_percent_n": 1,
+ "ingredients_with_specified_percent_sum": 54,
+ "ingredients_with_unspecified_percent_n": 15,
+ "ingredients_with_unspecified_percent_sum": 46,
+ "interface_version_created": "20120622",
+ "interface_version_modified": "20150316.jqm2",
+ "known_ingredients_n": 35,
+ "labels": "Vegetarian, No preservatives, No artificial anything",
+ "labels_hierarchy": [
+ "en:vegetarian",
+ "en:no-preservatives",
+ "en:No artificial anything"
+ ],
+ "labels_lc": "en",
+ "labels_old": "Vegetarian, No preservatives, No artificial anything",
+ "labels_tags": [
+ "en:vegetarian",
+ "en:no-preservatives",
+ "en:no-artificial-anything"
+ ],
+ "lang": "en",
+ "lang_debug_tags": [],
+ "languages": {
+ "en:english": 5
+ },
+ "languages_codes": {
+ "en": 5
+ },
+ "languages_hierarchy": [
+ "en:english"
+ ],
+ "languages_tags": [
+ "en:english",
+ "en:1"
+ ],
+ "last_edit_dates_tags": [
+ "2022-06-24",
+ "2022-06",
+ "2022"
+ ],
+ "last_editor": "kiliweb",
+ "last_image_dates_tags": [
+ "2022-06-24",
+ "2022-06",
+ "2022"
+ ],
+ "last_image_t": 1656075071,
+ "last_modified_by": "kiliweb",
+ "last_modified_t": 1656075071,
+ "lc": "en",
+ "link": "",
+ "link_debug_tags": [],
+ "main_countries_tags": [],
+ "manufacturing_places": "European Union",
+ "manufacturing_places_debug_tags": [],
+ "manufacturing_places_tags": [
+ "european-union"
+ ],
+ "max_imgid": "9",
+ "minerals_prev_tags": [],
+ "minerals_tags": [],
+ "misc_tags": [
+ "en:nutrition-fruits-vegetables-nuts-estimate-from-ingredients",
+ "en:nutrition-all-nutriscore-values-known",
+ "en:nutriscore-computed",
+ "en:ecoscore-extended-data-computed",
+ "en:ecoscore-extended-data-version-4",
+ "en:ecoscore-missing-data-warning",
+ "en:ecoscore-missing-data-labels",
+ "en:ecoscore-missing-data-origins",
+ "en:ecoscore-computed"
+ ],
+ "no_nutrition_data": "",
+ "nova_group": 4,
+ "nova_group_debug": "",
+ "nova_groups": "4",
+ "nova_groups_markers": {
+ "3": [
+ [
+ "categories",
+ "en:salty-snacks"
+ ],
+ [
+ "ingredients",
+ "en:salt"
+ ],
+ [
+ "ingredients",
+ "en:starch"
+ ],
+ [
+ "ingredients",
+ "en:sugar"
+ ],
+ [
+ "ingredients",
+ "en:vegetable-oil"
+ ]
+ ],
+ "4": [
+ [
+ "additives",
+ "en:e160c"
+ ],
+ [
+ "ingredients",
+ "en:colour"
+ ],
+ [
+ "ingredients",
+ "en:flavouring"
+ ],
+ [
+ "ingredients",
+ "en:whey"
+ ]
+ ]
+ },
+ "nova_groups_tags": [
+ "en:4-ultra-processed-food-and-drink-products"
+ ],
+ "nucleotides_prev_tags": [],
+ "nucleotides_tags": [],
+ "nutrient_levels": {
+ "fat": "moderate",
+ "salt": "high",
+ "saturated-fat": "low",
+ "sugars": "moderate"
+ },
+ "nutrient_levels_tags": [
+ "en:fat-in-moderate-quantity",
+ "en:saturated-fat-in-low-quantity",
+ "en:sugars-in-moderate-quantity",
+ "en:salt-in-high-quantity"
+ ],
+ "nutriments": {
+ "carbohydrates": 15,
+ "carbohydrates_100g": 15,
+ "carbohydrates_serving": 3.45,
+ "carbohydrates_unit": "g",
+ "carbohydrates_value": 15,
+ "carbon-footprint-from-known-ingredients_100g": 32.4,
+ "carbon-footprint-from-known-ingredients_product": 7.45,
+ "carbon-footprint-from-known-ingredients_serving": 7.45,
+ "energy": 1757,
+ "energy-kcal": 420,
+ "energy-kcal_100g": 420,
+ "energy-kcal_serving": 96.6,
+ "energy-kcal_unit": "kcal",
+ "energy-kcal_value": 420,
+ "energy_100g": 1757,
+ "energy_serving": 404,
+ "energy_unit": "kcal",
+ "energy_value": 420,
+ "fat": 15,
+ "fat_100g": 15,
+ "fat_serving": 3.45,
+ "fat_unit": "g",
+ "fat_value": 15,
+ "fiber": 3.9,
+ "fiber_100g": 3.9,
+ "fiber_serving": 0.897,
+ "fiber_unit": "g",
+ "fiber_value": 3.9,
+ "fruits-vegetables-nuts-estimate-from-ingredients_100g": 0,
+ "fruits-vegetables-nuts-estimate-from-ingredients_serving": 0,
+ "nova-group": 4,
+ "nova-group_100g": 4,
+ "nova-group_serving": 4,
+ "nutrition-score-fr": 12,
+ "nutrition-score-fr_100g": 12,
+ "proteins": 5.7,
+ "proteins_100g": 5.7,
+ "proteins_serving": 1.31,
+ "proteins_unit": "g",
+ "proteins_value": 5.7,
+ "salt": 2.1,
+ "salt_100g": 2.1,
+ "salt_serving": 0.483,
+ "salt_unit": "g",
+ "salt_value": 2.1,
+ "saturated-fat": 1.4,
+ "saturated-fat_100g": 1.4,
+ "saturated-fat_serving": 0.322,
+ "saturated-fat_unit": "g",
+ "saturated-fat_value": 1.4,
+ "sodium": 0.84,
+ "sodium_100g": 0.84,
+ "sodium_serving": 0.193,
+ "sodium_unit": "g",
+ "sodium_value": 0.84,
+ "sugars": 8.7,
+ "sugars_100g": 8.7,
+ "sugars_serving": 2,
+ "sugars_unit": "g",
+ "sugars_value": 8.7
+ },
+ "nutriscore_data": {
+ "energy": 1757,
+ "energy_points": 5,
+ "energy_value": 1757,
+ "fiber": 3.9,
+ "fiber_points": 4,
+ "fiber_value": 3.9,
+ "fruits_vegetables_nuts_colza_walnut_olive_oils": 0,
+ "fruits_vegetables_nuts_colza_walnut_olive_oils_points": 0,
+ "fruits_vegetables_nuts_colza_walnut_olive_oils_value": 0,
+ "grade": "d",
+ "is_beverage": 0,
+ "is_cheese": 0,
+ "is_fat": 0,
+ "is_water": 0,
+ "negative_points": 16,
+ "positive_points": 4,
+ "proteins": 5.7,
+ "proteins_points": 3,
+ "proteins_value": 5.7,
+ "saturated_fat": 1.4,
+ "saturated_fat_points": 1,
+ "saturated_fat_ratio": 9.33333333333333,
+ "saturated_fat_ratio_points": 0,
+ "saturated_fat_ratio_value": 9.3,
+ "saturated_fat_value": 1.4,
+ "score": 12,
+ "sodium": 840,
+ "sodium_points": 9,
+ "sodium_value": 840,
+ "sugars": 8.7,
+ "sugars_points": 1,
+ "sugars_value": 8.7
+ },
+ "nutriscore_grade": "d",
+ "nutriscore_score": 12,
+ "nutriscore_score_opposite": -12,
+ "nutrition_data": "on",
+ "nutrition_data_per": "100g",
+ "nutrition_data_prepared": "",
+ "nutrition_data_prepared_per": "100g",
+ "nutrition_data_prepared_per_debug_tags": [],
+ "nutrition_grade_fr": "d",
+ "nutrition_grades": "d",
+ "nutrition_grades_tags": [
+ "d"
+ ],
+ "nutrition_score_beverage": 0,
+ "nutrition_score_debug": "",
+ "nutrition_score_warning_fruits_vegetables_nuts_estimate_from_ingredients": 1,
+ "nutrition_score_warning_fruits_vegetables_nuts_estimate_from_ingredients_value": 0,
+ "origins": "",
+ "origins_hierarchy": [],
+ "origins_lc": "en",
+ "origins_old": "",
+ "origins_tags": [],
+ "other_nutritional_substances_tags": [],
+ "packaging": "Plastic, en:mixed plastic film-packet",
+ "packaging_hierarchy": [
+ "en:plastic",
+ "en:mixed plastic film-packet"
+ ],
+ "packaging_lc": "en",
+ "packaging_old": "Plastic, Mixed plastic-packet",
+ "packaging_old_before_taxonomization": "Plastic, en:mixed plastic-packet",
+ "packaging_tags": [
+ "en:plastic",
+ "en:mixed-plastic-film-packet"
+ ],
+ "packagings": [
+ {
+ "material": "en:plastic",
+ "shape": "en:pack"
+ }
+ ],
+ "photographers_tags": [
+ "kyzh",
+ "kiliweb"
+ ],
+ "pnns_groups_1": "Salty snacks",
+ "pnns_groups_1_tags": [
+ "salty-snacks",
+ "known"
+ ],
+ "pnns_groups_2": "Appetizers",
+ "pnns_groups_2_tags": [
+ "appetizers",
+ "known"
+ ],
+ "popularity_key": 20900000020,
+ "popularity_tags": [
+ "bottom-25-percent-scans-2019",
+ "bottom-20-percent-scans-2019",
+ "bottom-15-percent-scans-2019",
+ "top-90-percent-scans-2019",
+ "top-10000-gb-scans-2019",
+ "top-50000-gb-scans-2019",
+ "top-100000-gb-scans-2019",
+ "top-country-gb-scans-2019",
+ "bottom-25-percent-scans-2020",
+ "top-80-percent-scans-2020",
+ "top-85-percent-scans-2020",
+ "top-90-percent-scans-2020",
+ "top-5000-gb-scans-2020",
+ "top-10000-gb-scans-2020",
+ "top-50000-gb-scans-2020",
+ "top-100000-gb-scans-2020",
+ "top-country-gb-scans-2020",
+ "top-100000-scans-2021",
+ "at-least-5-scans-2021",
+ "top-75-percent-scans-2021",
+ "top-80-percent-scans-2021",
+ "top-85-percent-scans-2021",
+ "top-90-percent-scans-2021",
+ "top-5000-gb-scans-2021",
+ "top-10000-gb-scans-2021",
+ "top-50000-gb-scans-2021",
+ "top-100000-gb-scans-2021",
+ "top-country-gb-scans-2021",
+ "at-least-5-gb-scans-2021",
+ "top-5000-ie-scans-2021",
+ "top-10000-ie-scans-2021",
+ "top-50000-ie-scans-2021",
+ "top-100000-ie-scans-2021",
+ "top-1000-mu-scans-2021",
+ "top-5000-mu-scans-2021",
+ "top-10000-mu-scans-2021",
+ "top-50000-mu-scans-2021",
+ "top-100000-mu-scans-2021"
+ ],
+ "product_name": "Barbeque Potato Chips",
+ "product_name_en": "Barbeque Potato Chips",
+ "product_name_fr": "",
+ "product_name_fr_debug_tags": [],
+ "product_quantity": "23",
+ "purchase_places": "",
+ "purchase_places_debug_tags": [],
+ "purchase_places_tags": [],
+ "quantity": "23 g",
+ "quantity_debug_tags": [],
+ "removed_countries_tags": [],
+ "rev": 32,
+ "scans_n": 10,
+ "selected_images": {
+ "front": {
+ "display": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.400.jpg"
+ },
+ "small": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.200.jpg"
+ },
+ "thumb": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/front_en.23.100.jpg"
+ }
+ },
+ "ingredients": {
+ "display": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.400.jpg"
+ },
+ "small": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.200.jpg"
+ },
+ "thumb": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/ingredients_en.11.100.jpg"
+ }
+ },
+ "nutrition": {
+ "display": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.400.jpg"
+ },
+ "small": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.200.jpg"
+ },
+ "thumb": {
+ "en": "https://images.openfoodfacts.org/images/products/506/029/230/2201/nutrition_en.32.100.jpg"
+ }
+ }
+ },
+ "serving_quantity": "23",
+ "serving_size": "23 g",
+ "serving_size_debug_tags": [],
+ "sortkey": 1535456524,
+ "states": "en:to-be-completed, en:nutrition-facts-completed, en:ingredients-completed, en:expiration-date-completed, en:packaging-code-to-be-completed, en:characteristics-to-be-completed, en:origins-to-be-completed, en:categories-completed, en:brands-completed, en:packaging-completed, en:quantity-completed, en:product-name-completed, en:photos-to-be-validated, en:packaging-photo-to-be-selected, en:nutrition-photo-selected, en:ingredients-photo-selected, en:front-photo-selected, en:photos-uploaded",
+ "states_hierarchy": [
+ "en:to-be-completed",
+ "en:nutrition-facts-completed",
+ "en:ingredients-completed",
+ "en:expiration-date-completed",
+ "en:packaging-code-to-be-completed",
+ "en:characteristics-to-be-completed",
+ "en:origins-to-be-completed",
+ "en:categories-completed",
+ "en:brands-completed",
+ "en:packaging-completed",
+ "en:quantity-completed",
+ "en:product-name-completed",
+ "en:photos-to-be-validated",
+ "en:packaging-photo-to-be-selected",
+ "en:nutrition-photo-selected",
+ "en:ingredients-photo-selected",
+ "en:front-photo-selected",
+ "en:photos-uploaded"
+ ],
+ "states_tags": [
+ "en:to-be-completed",
+ "en:nutrition-facts-completed",
+ "en:ingredients-completed",
+ "en:expiration-date-completed",
+ "en:packaging-code-to-be-completed",
+ "en:characteristics-to-be-completed",
+ "en:origins-to-be-completed",
+ "en:categories-completed",
+ "en:brands-completed",
+ "en:packaging-completed",
+ "en:quantity-completed",
+ "en:product-name-completed",
+ "en:photos-to-be-validated",
+ "en:packaging-photo-to-be-selected",
+ "en:nutrition-photo-selected",
+ "en:ingredients-photo-selected",
+ "en:front-photo-selected",
+ "en:photos-uploaded"
+ ],
+ "stores": "",
+ "stores_debug_tags": [],
+ "stores_tags": [],
+ "teams": "swipe-studio",
+ "teams_tags": [
+ "swipe-studio"
+ ],
+ "traces": "",
+ "traces_debug_tags": [],
+ "traces_from_ingredients": "",
+ "traces_from_user": "(en) ",
+ "traces_hierarchy": [],
+ "traces_tags": [],
+ "unique_scans_n": 8,
+ "unknown_ingredients_n": 1,
+ "unknown_nutrients_tags": [],
+ "update_key": "update20221107",
+ "vitamins_prev_tags": [],
+ "vitamins_tags": []
+ },
+ "status": 1,
+ "status_verbose": "product found"
+}
diff --git a/examples/json_tree.py b/examples/json_tree.py
new file mode 100644
index 000000000..d844556bb
--- /dev/null
+++ b/examples/json_tree.py
@@ -0,0 +1,79 @@
+import json
+
+from rich.text import Text
+
+from textual.app import App, ComposeResult
+from textual.widgets import Header, Footer, Tree, TreeNode
+
+
+class TreeApp(App):
+
+ BINDINGS = [
+ ("a", "add", "Add node"),
+ ("c", "clear", "Clear"),
+ ("t", "toggle_root", "Toggle root"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Footer()
+ yield Tree("Root")
+
+ @classmethod
+ def add_json(cls, node: TreeNode, json_data: object) -> None:
+ """Adds JSON data to a node.
+
+ Args:
+ node (TreeNode): A Tree node.
+ json_data (object): An object decoded from JSON.
+ """
+
+ from rich.highlighter import ReprHighlighter
+
+ highlighter = ReprHighlighter()
+
+ def add_node(name: str, node: TreeNode, data: object) -> None:
+ if isinstance(data, dict):
+ node._label = Text(f"{{}} {name}")
+ for key, value in data.items():
+ new_node = node.add("")
+ add_node(key, new_node, value)
+ elif isinstance(data, list):
+ node._label = Text(f"[] {name}")
+ for index, value in enumerate(data):
+ new_node = node.add("")
+ add_node(str(index), new_node, value)
+ else:
+ node._allow_expand = False
+ if name:
+ label = Text.assemble(
+ Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data))
+ )
+ else:
+ label = Text(repr(data))
+ node._label = label
+
+ add_node("JSON", node, json_data)
+
+ def on_mount(self) -> None:
+ with open("food.json") as data_file:
+ self.json_data = json.load(data_file)
+
+ def action_add(self) -> None:
+ tree = self.query_one(Tree)
+ json_node = tree.root.add("JSON")
+ self.add_json(json_node, self.json_data)
+ tree.root.expand()
+
+ def action_clear(self) -> None:
+ tree = self.query_one(Tree)
+ tree.clear()
+
+ def action_toggle_root(self) -> None:
+ tree = self.query_one(Tree)
+ tree.show_root = not tree.show_root
+
+
+if __name__ == "__main__":
+ app = TreeApp()
+ app.run()
diff --git a/mkdocs.yml b/mkdocs.yml
index 28f7180cf..7a1a14c99 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -89,18 +89,19 @@ nav:
- "styles/visibility.md"
- "styles/width.md"
- Widgets:
- - "widgets/index.md"
- "widgets/button.md"
- "widgets/checkbox.md"
- "widgets/data_table.md"
+ - "widgets/directory_tree.md"
- "widgets/footer.md"
- "widgets/header.md"
+ - "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
- "widgets/list_view.md"
- "widgets/list_item.md"
- "widgets/static.md"
- - "widgets/tree_control.md"
+ - "widgets/tree.md"
- API:
- "api/index.md"
- "api/app.md"
diff --git a/src/textual/_cache.py b/src/textual/_cache.py
index 2f9bdd49d..d6b877d30 100644
--- a/src/textual/_cache.py
+++ b/src/textual/_cache.py
@@ -47,6 +47,7 @@ class LRUCache(Generic[CacheKey, CacheValue]):
@property
def maxsize(self) -> int:
+ """int: Maximum size of cache, before new values evict old values."""
return self._maxsize
@maxsize.setter
@@ -59,6 +60,14 @@ class LRUCache(Generic[CacheKey, CacheValue]):
def __len__(self) -> int:
return len(self._cache)
+ def grow(self, maxsize: int) -> None:
+ """Grow the maximum size to at least `maxsize` elements.
+
+ Args:
+ maxsize (int): New maximum size.
+ """
+ self.maxsize = max(self.maxsize, maxsize)
+
def clear(self) -> None:
"""Clear the cache."""
with self._lock:
diff --git a/src/textual/box_model.py b/src/textual/box_model.py
index 8dadfc684..18ca78225 100644
--- a/src/textual/box_model.py
+++ b/src/textual/box_model.py
@@ -62,6 +62,8 @@ def get_box_model(
content_width = Fraction(
get_content_width(content_container - styles.margin.totals, viewport)
)
+ if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
+ content_width += styles.scrollbar_size_vertical
else:
# An explicit width
styles_width = styles.width
@@ -97,6 +99,8 @@ def get_box_model(
content_height = Fraction(
get_content_height(content_container, viewport, int(content_width))
)
+ if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto":
+ content_height += styles.scrollbar_size_horizontal
else:
styles_height = styles.height
# Explicit height set
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 006f12910..c2bf84364 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -551,6 +551,22 @@ class StylesBase(ABC):
self._align_height(height, parent_height),
)
+ @property
+ def partial_rich_style(self) -> Style:
+ """Get the style properties associated with this node only (not including parents in the DOM).
+
+ Returns:
+ Style: Rich Style object.
+ """
+ style = Style(
+ color=(self.color.rich_color if self.has_rule("color") else None),
+ bgcolor=(
+ self.background.rich_color if self.has_rule("background") else None
+ ),
+ )
+ style += self.text_style
+ return style
+
@rich.repr.auto
@dataclass
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 19a368bf1..f1232e54d 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -22,7 +22,7 @@ from rich.tree import Tree
from ._context import NoActiveAppError
from ._node_list import NodeList
-from .binding import Bindings, BindingType
+from .binding import Binding, Bindings, BindingType
from .color import BLACK, WHITE, Color
from .css._error_tools import friendly_list
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
@@ -97,9 +97,16 @@ class DOMNode(MessagePump):
# True if this node inherits the CSS from the base class.
_inherit_css: ClassVar[bool] = True
+
+ # True to inherit bindings from base class
+ _inherit_bindings: ClassVar[bool] = True
+
# List of names of base classes that inherit CSS
_css_type_names: ClassVar[frozenset[str]] = frozenset()
+ # Generated list of bindings
+ _merged_bindings: ClassVar[Bindings] | None = None
+
def __init__(
self,
*,
@@ -127,7 +134,7 @@ class DOMNode(MessagePump):
self._auto_refresh: float | None = None
self._auto_refresh_timer: Timer | None = None
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
- self._bindings = Bindings(self.BINDINGS)
+ self._bindings = self._merged_bindings or Bindings()
self._has_hover_style: bool = False
self._has_focus_within: bool = False
@@ -152,12 +159,16 @@ class DOMNode(MessagePump):
"""Perform an automatic refresh (set with auto_refresh property)."""
self.refresh()
- def __init_subclass__(cls, inherit_css: bool = True) -> None:
+ def __init_subclass__(
+ cls, inherit_css: bool = True, inherit_bindings: bool = True
+ ) -> None:
super().__init_subclass__()
cls._inherit_css = inherit_css
+ cls._inherit_bindings = inherit_bindings
css_type_names: set[str] = set()
for base in cls._css_bases(cls):
css_type_names.add(base.__name__)
+ cls._merged_bindings = cls._merge_bindings()
cls._css_type_names = frozenset(css_type_names)
def get_component_styles(self, name: str) -> RenderStyles:
@@ -205,6 +216,25 @@ class DOMNode(MessagePump):
else:
break
+ @classmethod
+ def _merge_bindings(cls) -> Bindings:
+ """Merge bindings from base classes.
+
+ Returns:
+ Bindings: Merged bindings.
+ """
+ bindings: list[Bindings] = []
+
+ for base in reversed(cls.__mro__):
+ if issubclass(base, DOMNode):
+ if not base._inherit_bindings:
+ bindings.clear()
+ bindings.append(Bindings(base.BINDINGS))
+ keys = {}
+ for bindings_ in bindings:
+ keys.update(bindings_.keys)
+ return Bindings(keys.values())
+
def _post_register(self, app: App) -> None:
"""Called when the widget is registered
diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py
index 8f61c4fb2..f5f90ae0b 100644
--- a/src/textual/drivers/linux_driver.py
+++ b/src/textual/drivers/linux_driver.py
@@ -176,6 +176,7 @@ class LinuxDriver(Driver):
self.exit_event.set()
if self._key_thread is not None:
self._key_thread.join()
+ self.exit_event.clear()
termios.tcflush(self.fileno, termios.TCIFLUSH)
except Exception as error:
# TODO: log this
diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py
index b14af7ab5..0899f65ef 100644
--- a/src/textual/drivers/windows_driver.py
+++ b/src/textual/drivers/windows_driver.py
@@ -84,6 +84,7 @@ class WindowsDriver(Driver):
if self._event_thread is not None:
self._event_thread.join()
self._event_thread = None
+ self.exit_event.clear()
except Exception as error:
# TODO: log this
pass
diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py
index 0fc2a4cc9..e6e692480 100644
--- a/src/textual/scroll_view.py
+++ b/src/textual/scroll_view.py
@@ -75,8 +75,9 @@ class ScrollView(Widget):
):
self._size = size
virtual_size = self.virtual_size
- self._scroll_update(virtual_size)
self._container_size = size - self.styles.gutter.totals
+ self._scroll_update(virtual_size)
+
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
self.refresh()
diff --git a/src/textual/widget.py b/src/textual/widget.py
index fcc8aa328..3dc821e95 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -1,15 +1,16 @@
from __future__ import annotations
from collections import Counter
-from asyncio import Lock, wait, create_task, Event as AsyncEvent
+from asyncio import Event as AsyncEvent
+from asyncio import Lock, create_task, wait
from fractions import Fraction
from itertools import islice
from operator import attrgetter
from typing import (
- Generator,
TYPE_CHECKING,
ClassVar,
Collection,
+ Generator,
Iterable,
NamedTuple,
Sequence,
@@ -32,7 +33,7 @@ from rich.style import Style
from rich.text import Text
from . import errors, events, messages
-from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction
+from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
from ._arrange import DockArrangeResult, arrange
from ._context import active_app
from ._easing import DEFAULT_SCROLL_EASING
@@ -40,7 +41,8 @@ from ._layout import Layout
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from ._types import Lines
-from .binding import NoBinding
+from .await_remove import AwaitRemove
+from .binding import Binding
from .box_model import BoxModel, get_box_model
from .css.query import NoMatches
from .css.scalar import ScalarOffset
@@ -169,6 +171,17 @@ class Widget(DOMNode):
"""
+ BINDINGS = [
+ Binding("up", "scroll_up", "Scroll Up", show=False),
+ Binding("down", "scroll_down", "Scroll Down", show=False),
+ Binding("left", "scroll_left", "Scroll Up", show=False),
+ Binding("right", "scroll_right", "Scroll Right", show=False),
+ Binding("home", "scroll_home", "Scroll Home", show=False),
+ Binding("end", "scroll_end", "Scroll End", show=False),
+ Binding("pageup", "page_up", "Page Up", show=False),
+ Binding("pagedown", "page_down", "Page Down", show=False),
+ ]
+
DEFAULT_CSS = """
Widget{
scrollbar-background: $panel-darken-1;
@@ -237,7 +250,7 @@ class Widget(DOMNode):
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
self._styles_cache = StylesCache()
- self._rich_style_cache: dict[str, Style] = {}
+ self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
self._stabilized_scrollbar_size: Size | None = None
self._lock = Lock()
@@ -374,20 +387,26 @@ class Widget(DOMNode):
pass
raise NoMatches(f"No descendant found with id={id!r}")
- def get_component_rich_style(self, name: str) -> Style:
+ def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
"""Get a *Rich* style for a component.
Args:
name (str): Name of component.
+ partial (bool, optional): Return a partial style (not combined with parent).
Returns:
Style: A Rich style object.
"""
- style = self._rich_style_cache.get(name)
- if style is None:
- style = self.get_component_styles(name).rich_style
- self._rich_style_cache[name] = style
- return style
+
+ if name not in self._rich_style_cache:
+ component_styles = self.get_component_styles(name)
+ style = component_styles.rich_style
+ partial_style = component_styles.partial_rich_style
+ self._rich_style_cache[name] = (style, partial_style)
+
+ style, partial_style = self._rich_style_cache[name]
+
+ return partial_style if partial else style
def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.
@@ -903,8 +922,6 @@ class Widget(DOMNode):
int: Number of rows in the horizontal scrollbar.
"""
styles = self.styles
- if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
- return styles.scrollbar_size_horizontal
return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0
@property
@@ -966,6 +983,18 @@ class Widget(DOMNode):
content_region = self.region.shrink(self.styles.gutter)
return content_region
+ @property
+ def scrollable_content_region(self) -> Region:
+ """Gets an absolute region containing the scrollable content (minus padding, border, and scrollbars).
+
+ Returns:
+ Region: Screen region that contains a widget's content.
+ """
+ content_region = self.region.shrink(self.styles.gutter).shrink(
+ self.scrollbar_gutter
+ )
+ return content_region
+
@property
def content_offset(self) -> Offset:
"""An offset from the Widget origin where the content begins.
@@ -1731,7 +1760,7 @@ class Widget(DOMNode):
Returns:
Offset: The distance that was scrolled.
"""
- window = self.content_region.at_offset(self.scroll_offset)
+ window = self.scrollable_content_region.at_offset(self.scroll_offset)
if spacing is not None:
window = window.shrink(spacing)
@@ -1793,9 +1822,13 @@ class Widget(DOMNode):
can_focus: bool | None = None,
can_focus_children: bool | None = None,
inherit_css: bool = True,
+ inherit_bindings: bool = True,
) -> None:
base = cls.__mro__[0]
- super().__init_subclass__(inherit_css=inherit_css)
+ super().__init_subclass__(
+ inherit_css=inherit_css,
+ inherit_bindings=inherit_bindings,
+ )
if issubclass(base, Widget):
cls.can_focus = base.can_focus if can_focus is None else can_focus
cls.can_focus_children = (
@@ -2322,50 +2355,34 @@ class Widget(DOMNode):
def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None:
self.scroll_to_region(message.region, animate=True)
- def _key_home(self) -> bool:
+ def action_scroll_home(self) -> None:
if self._allow_scroll:
self.scroll_home()
- return True
- return False
- def _key_end(self) -> bool:
+ def action_scroll_end(self) -> None:
if self._allow_scroll:
self.scroll_end()
- return True
- return False
- def _key_left(self) -> bool:
+ def action_scroll_left(self) -> None:
if self.allow_horizontal_scroll:
self.scroll_left()
- return True
- return False
- def _key_right(self) -> bool:
+ def action_scroll_right(self) -> None:
if self.allow_horizontal_scroll:
self.scroll_right()
- return True
- return False
- def _key_down(self) -> bool:
- if self.allow_vertical_scroll:
- self.scroll_down()
- return True
- return False
-
- def _key_up(self) -> bool:
+ def action_scroll_up(self) -> None:
if self.allow_vertical_scroll:
self.scroll_up()
- return True
- return False
- def _key_pagedown(self) -> bool:
+ def action_scroll_down(self) -> None:
+ if self.allow_vertical_scroll:
+ self.scroll_down()
+
+ def action_page_down(self) -> None:
if self.allow_vertical_scroll:
self.scroll_page_down()
- return True
- return False
- def _key_pageup(self) -> bool:
+ def action_page_up(self) -> None:
if self.allow_vertical_scroll:
self.scroll_page_up()
- return True
- return False
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index 533e0ba86..4628cbe92 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -8,23 +8,25 @@ from ..case import camel_to_snake
# but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't
# be able to "see" them.
if typing.TYPE_CHECKING:
- from ..widget import Widget
from ._button import Button
from ._checkbox import Checkbox
from ._data_table import DataTable
from ._directory_tree import DirectoryTree
from ._footer import Footer
from ._header import Header
- from ._label import Label
- from ._list_view import ListView
- from ._list_item import ListItem
- from ._pretty import Pretty
- from ._placeholder import Placeholder
- from ._static import Static
from ._input import Input
+ from ._label import Label
+ from ._list_item import ListItem
+ from ._list_view import ListView
+ from ._placeholder import Placeholder
+ from ._pretty import Pretty
+ from ._static import Static
from ._text_log import TextLog
- from ._tree_control import TreeControl
+ from ._tree import Tree
+ from ._tree_node import TreeNode
from ._welcome import Welcome
+ from ..widget import Widget
+
__all__ = [
"Button",
@@ -33,15 +35,16 @@ __all__ = [
"DirectoryTree",
"Footer",
"Header",
+ "Input",
+ "Label",
"ListItem",
"ListView",
- "Label",
"Placeholder",
"Pretty",
"Static",
- "Input",
"TextLog",
- "TreeControl",
+ "Tree",
+ "TreeNode",
"Welcome",
]
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 2c6cb36d4..82d25cd90 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -13,5 +13,6 @@ from ._pretty import Pretty as Pretty
from ._static import Static as Static
from ._input import Input as Input
from ._text_log import TextLog as TextLog
-from ._tree_control import TreeControl as TreeControl
+from ._tree import Tree as Tree
+from ._tree_node import TreeNode as TreeNode
from ._welcome import Welcome as Welcome
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index 6d5c9dd01..5d6d0a0e0 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -652,7 +652,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
region = self._get_cell_region(self.cursor_row, self.cursor_column)
- spacing = self._get_cell_border() + self.scrollbar_gutter
+ spacing = self._get_cell_border()
self.scroll_to_region(region, animate=animate, spacing=spacing)
def on_click(self, event: events.Click) -> None:
diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py
index 4961db81d..459afa1ea 100644
--- a/src/textual/widgets/_directory_tree.py
+++ b/src/textual/widgets/_directory_tree.py
@@ -1,29 +1,61 @@
from __future__ import annotations
from dataclasses import dataclass
-from functools import lru_cache
-from os import scandir
-import os.path
+from pathlib import Path
+from typing import ClassVar
-from rich.console import RenderableType
-import rich.repr
-
-from rich.text import Text
+from rich.style import Style
+from rich.text import Text, TextType
from ..message import Message
+from ._tree import Tree, TreeNode, TOGGLE_STYLE
from .._types import MessageTarget
-from ._tree_control import TreeControl, TreeNode
@dataclass
class DirEntry:
+ """Attaches directory information ot a node."""
+
path: str
is_dir: bool
+ loaded: bool = False
-class DirectoryTree(TreeControl[DirEntry]):
- @rich.repr.auto
- class FileClick(Message, bubble=True):
+class DirectoryTree(Tree[DirEntry]):
+
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
+ "tree--label",
+ "tree--guides",
+ "tree--guides-hover",
+ "tree--guides-selected",
+ "tree--cursor",
+ "tree--highlight",
+ "tree--highlight-line",
+ "directory-tree--folder",
+ "directory-tree--file",
+ "directory-tree--extension",
+ "directory-tree--hidden",
+ }
+
+ DEFAULT_CSS = """
+ DirectoryTree > .directory-tree--folder {
+ text-style: bold;
+ }
+
+ DirectoryTree > .directory-tree--file {
+
+ }
+
+ DirectoryTree > .directory-tree--extension {
+ text-style: italic;
+ }
+
+ DirectoryTree > .directory-tree--hidden {
+ color: $text 50%;
+ }
+ """
+
+ class FileSelected(Message, bubble=True):
def __init__(self, sender: MessageTarget, path: str) -> None:
self.path = path
super().__init__(sender)
@@ -36,84 +68,97 @@ class DirectoryTree(TreeControl[DirEntry]):
id: str | None = None,
classes: str | None = None,
) -> None:
- self.path = os.path.expanduser(path.rstrip("/"))
- label = os.path.basename(self.path)
- data = DirEntry(self.path, True)
- super().__init__(label, data, name=name, id=id, classes=classes)
- self.root.tree.guide_style = "black"
-
- def render_node(self, node: TreeNode[DirEntry]) -> RenderableType:
- return self.render_tree_label(
- node,
- node.data.is_dir,
- node.expanded,
- node.is_cursor,
- node.id == self.hover_node,
- self.has_focus,
+ self.path = path
+ super().__init__(
+ path,
+ data=DirEntry(path, True),
+ name=name,
+ id=id,
+ classes=classes,
)
- @lru_cache(maxsize=1024 * 32)
- def render_tree_label(
- self,
- node: TreeNode[DirEntry],
- is_dir: bool,
- expanded: bool,
- is_cursor: bool,
- is_hover: bool,
- has_focus: bool,
- ) -> RenderableType:
- meta = {
- "@click": f"click_label({node.id})",
- "tree_node": node.id,
- "cursor": node.is_cursor,
- }
- label = Text(node.label) if isinstance(node.label, str) else node.label
- if is_hover:
- label.stylize("underline")
- if is_dir:
- label.stylize("bold")
- icon = "📂" if expanded else "📁"
+ def process_label(self, label: TextType):
+ """Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
+
+ Args:
+ label (TextType): Label.
+
+ Returns:
+ Text: A Rich Text object.
+ """
+ if isinstance(label, str):
+ text_label = Text(label)
else:
- icon = "📄"
- label.highlight_regex(r"\..*$", "italic")
+ text_label = label
+ first_line = text_label.split()[0]
+ return first_line
- if label.plain.startswith("."):
- label.stylize("dim")
+ def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style):
+ node_label = node._label.copy()
+ node_label.stylize(style)
- if is_cursor and has_focus:
- cursor_style = self.get_component_styles("tree--cursor").rich_style
- label.stylize(cursor_style)
+ if node._allow_expand:
+ prefix = ("📂 " if node.is_expanded else "📁 ", base_style + TOGGLE_STYLE)
+ node_label.stylize_before(
+ self.get_component_rich_style("directory-tree--folder", partial=True)
+ )
+ else:
+ prefix = (
+ "📄 ",
+ base_style,
+ )
+ node_label.stylize_before(
+ self.get_component_rich_style("directory-tree--file", partial=True),
+ )
+ node_label.highlight_regex(
+ r"\..+$",
+ self.get_component_rich_style(
+ "directory-tree--extension", partial=True
+ ),
+ )
- icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label
- icon_label.apply_meta(meta)
- return icon_label
+ if node_label.plain.startswith("."):
+ node_label.stylize_before(
+ self.get_component_rich_style("directory-tree--hidden")
+ )
- def on_styles_updated(self) -> None:
- self.render_tree_label.cache_clear()
+ text = Text.assemble(prefix, node_label)
+ return text
+
+ def load_directory(self, node: TreeNode[DirEntry]) -> None:
+ assert node.data is not None
+ dir_path = Path(node.data.path)
+ node.data.loaded = True
+ directory = sorted(
+ list(dir_path.iterdir()),
+ key=lambda path: (not path.is_dir(), path.name.lower()),
+ )
+ for path in directory:
+ node.add(
+ path.name,
+ data=DirEntry(str(path), path.is_dir()),
+ allow_expand=path.is_dir(),
+ )
+ node.expand()
def on_mount(self) -> None:
- self.call_after_refresh(self.load_directory, self.root)
+ self.load_directory(self.root)
- async def load_directory(self, node: TreeNode[DirEntry]):
- path = node.data.path
- directory = sorted(
- list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name)
- )
- for entry in directory:
- node.add(entry.name, DirEntry(entry.path, entry.is_dir()))
- node.loaded = True
- node.expand()
- self.refresh(layout=True)
-
- async def on_tree_control_node_selected(
- self, message: TreeControl.NodeSelected[DirEntry]
- ) -> None:
- dir_entry = message.node.data
- if not dir_entry.is_dir:
- await self.emit(self.FileClick(self, dir_entry.path))
+ def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None:
+ event.stop()
+ dir_entry = event.node.data
+ if dir_entry is None:
+ return
+ if dir_entry.is_dir:
+ if not dir_entry.loaded:
+ self.load_directory(event.node)
else:
- if not message.node.loaded:
- await self.load_directory(message.node)
- message.node.expand()
- else:
- message.node.toggle()
+ self.emit_no_wait(self.FileSelected(self, dir_entry.path))
+
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
+ event.stop()
+ dir_entry = event.node.data
+ if dir_entry is None:
+ return
+ if not dir_entry.is_dir:
+ self.emit_no_wait(self.FileSelected(self, dir_entry.path))
diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
new file mode 100644
index 000000000..ac3ca5a61
--- /dev/null
+++ b/src/textual/widgets/_tree.py
@@ -0,0 +1,849 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import ClassVar, Generic, NewType, TypeVar
+
+import rich.repr
+from rich.segment import Segment
+from rich.style import Style, NULL_STYLE
+from rich.text import Text, TextType
+
+
+from ..binding import Binding
+from ..geometry import clamp, Region, Size
+from .._loop import loop_last
+from .._cache import LRUCache
+from ..message import Message
+from ..reactive import reactive, var
+from .._segment_tools import line_crop, line_pad
+from .._types import MessageTarget
+from .._typing import TypeAlias
+from ..scroll_view import ScrollView
+
+from .. import events
+
+NodeID = NewType("NodeID", int)
+TreeDataType = TypeVar("TreeDataType")
+EventTreeDataType = TypeVar("EventTreeDataType")
+
+LineCacheKey: TypeAlias = "tuple[int | tuple, ...]"
+
+TOGGLE_STYLE = Style.from_meta({"toggle": True})
+
+
+@dataclass
+class _TreeLine:
+ path: list[TreeNode]
+ last: bool
+
+ @property
+ def node(self) -> TreeNode:
+ """TreeNode: The node associated with this line."""
+ return self.path[-1]
+
+ def _get_guide_width(self, guide_depth: int, show_root: bool) -> int:
+ """Get the cell width of the line as rendered.
+
+ Args:
+ guide_depth (int): The guide depth (cells in the indentation).
+
+ Returns:
+ int: Width in cells.
+ """
+ guides = max(0, len(self.path) - (1 if show_root else 2)) * guide_depth
+ return guides
+
+
+@rich.repr.auto
+class TreeNode(Generic[TreeDataType]):
+ """An object that represents a "node" in a tree control."""
+
+ def __init__(
+ self,
+ tree: Tree[TreeDataType],
+ parent: TreeNode[TreeDataType] | None,
+ id: NodeID,
+ label: Text,
+ data: TreeDataType | None = None,
+ *,
+ expanded: bool = True,
+ allow_expand: bool = True,
+ ) -> None:
+ self._tree = tree
+ self._parent = parent
+ self._id = id
+ self._label = label
+ self.data = data
+ self._expanded = expanded
+ self._children: list[TreeNode] = []
+
+ self._hover_ = False
+ self._selected_ = False
+ self._allow_expand = allow_expand
+ self._updates: int = 0
+ self._line: int = -1
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield self._label.plain
+ yield self.data
+
+ def _reset(self) -> None:
+ self._hover_ = False
+ self._selected_ = False
+ self._updates += 1
+
+ @property
+ def line(self) -> int:
+ """int: Get the line number for this node, or -1 if it is not displayed."""
+ return self._line
+
+ @property
+ def _hover(self) -> bool:
+ """bool: Check if the mouse is over the node."""
+ return self._hover_
+
+ @_hover.setter
+ def _hover(self, hover: bool) -> None:
+ self._updates += 1
+ self._hover_ = hover
+
+ @property
+ def _selected(self) -> bool:
+ """bool: Check if the node is selected."""
+ return self._selected_
+
+ @_selected.setter
+ def _selected(self, selected: bool) -> None:
+ self._updates += 1
+ self._selected_ = selected
+
+ @property
+ def id(self) -> NodeID:
+ """NodeID: Get the node ID."""
+ return self._id
+
+ @property
+ def is_expanded(self) -> bool:
+ """bool: Check if the node is expanded."""
+ return self._expanded
+
+ @property
+ def is_last(self) -> bool:
+ """bool: Check if this is the last child."""
+ if self._parent is None:
+ return True
+ return bool(
+ self._parent._children and self._parent._children[-1] == self,
+ )
+
+ @property
+ def allow_expand(self) -> bool:
+ """bool: Check if the node is allowed to expand."""
+ return self._allow_expand
+
+ @allow_expand.setter
+ def allow_expand(self, allow_expand: bool) -> None:
+ self._allow_expand = allow_expand
+ self._updates += 1
+
+ def expand(self) -> None:
+ """Expand a node (show its children)."""
+ self._expanded = True
+ self._updates += 1
+ self._tree._invalidate()
+
+ def collapse(self) -> None:
+ """Collapse the node (hide children)."""
+ self._expanded = False
+ self._updates += 1
+ self._tree._invalidate()
+
+ def toggle(self) -> None:
+ """Toggle the expanded state."""
+ self._expanded = not self._expanded
+ self._updates += 1
+ self._tree._invalidate()
+
+ def set_label(self, label: TextType) -> None:
+ """Set a new label for the node.
+
+ Args:
+ label (TextType): A str or Text object with the new label.
+ """
+ self._updates += 1
+ text_label = self._tree.process_label(label)
+ self._label = text_label
+
+ def add(
+ self,
+ label: TextType,
+ data: TreeDataType | None = None,
+ *,
+ expand: bool = False,
+ allow_expand: bool = True,
+ ) -> TreeNode[TreeDataType]:
+ """Add a node to the sub-tree.
+
+ Args:
+ label (TextType): The new node's label.
+ data (TreeDataType): Data associated with the new node.
+ expand (bool, optional): Node should be expanded. Defaults to True.
+ allow_expand (bool, optional): Allow use to expand the node via keyboard or mouse. Defaults to True.
+
+ Returns:
+ TreeNode[TreeDataType]: A new Tree node
+ """
+ text_label = self._tree.process_label(label)
+ node = self._tree._add_node(self, text_label, data)
+ node._expanded = expand
+ node._allow_expand = allow_expand
+ self._updates += 1
+ self._children.append(node)
+ self._tree._invalidate()
+ return node
+
+ def add_leaf(
+ self, label: TextType, data: TreeDataType | None = None
+ ) -> TreeNode[TreeDataType]:
+ """Add a 'leaf' node (a node that can not expand).
+
+ Args:
+ label (TextType): Label for the node.
+ data (TreeDataType | None, optional): Optional data. Defaults to None.
+
+ Returns:
+ TreeNode[TreeDataType]: New node.
+ """
+ node = self.add(label, data, expand=False, allow_expand=False)
+ return node
+
+
+class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
+
+ BINDINGS = [
+ Binding("enter", "select_cursor", "Select", show=False),
+ Binding("up", "cursor_up", "Cursor Up", show=False),
+ Binding("down", "cursor_down", "Cursor Down", show=False),
+ ]
+
+ DEFAULT_CSS = """
+ Tree {
+ background: $panel;
+ color: $text;
+ }
+ Tree > .tree--label {
+
+ }
+ Tree > .tree--guides {
+ color: $success-darken-3;
+ }
+
+ Tree > .tree--guides-hover {
+ color: $success;
+ text-style: bold;
+ }
+
+ Tree > .tree--guides-selected {
+ color: $warning;
+ text-style: bold;
+ }
+
+ Tree > .tree--cursor {
+ background: $secondary;
+ color: $text;
+ text-style: bold;
+ }
+
+ Tree > .tree--highlight {
+ text-style: underline;
+ }
+
+ Tree > .tree--highlight-line {
+ background: $boost;
+ }
+
+ """
+
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
+ "tree--label",
+ "tree--guides",
+ "tree--guides-hover",
+ "tree--guides-selected",
+ "tree--cursor",
+ "tree--highlight",
+ "tree--highlight-line",
+ }
+
+ show_root = reactive(True)
+ """bool: Show the root of the tree."""
+ hover_line = var(-1)
+ """int: The line number under the mouse pointer, or -1 if not under the mouse pointer."""
+ cursor_line = var(-1)
+ """int: The line with the cursor, or -1 if no cursor."""
+ show_guides = reactive(True)
+ """bool: Enable display of tree guide lines."""
+ guide_depth = reactive(4, init=False)
+ """int: The indent depth of tree nodes."""
+ auto_expand = var(True)
+ """bool: Auto expand tree nodes when clicked."""
+
+ LINES: dict[str, tuple[str, str, str, str]] = {
+ "default": (
+ " ",
+ "│ ",
+ "└─",
+ "├─",
+ ),
+ "bold": (
+ " ",
+ "┃ ",
+ "┗━",
+ "┣━",
+ ),
+ "double": (
+ " ",
+ "║ ",
+ "╚═",
+ "╠═",
+ ),
+ }
+
+ class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
+ """Event sent when a node is selected."""
+
+ def __init__(
+ self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
+ ) -> None:
+ self.node = node
+ super().__init__(sender)
+
+ class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
+ """Event sent when a node is expanded."""
+
+ def __init__(
+ self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
+ ) -> None:
+ self.node = node
+ super().__init__(sender)
+
+ class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True):
+ """Event sent when a node is collapsed."""
+
+ def __init__(
+ self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
+ ) -> None:
+ self.node = node
+ super().__init__(sender)
+
+ def __init__(
+ self,
+ label: TextType,
+ data: TreeDataType | None = None,
+ *,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ ) -> None:
+ super().__init__(name=name, id=id, classes=classes)
+
+ text_label = self.process_label(label)
+
+ self._updates = 0
+ self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {}
+ self._current_id = 0
+ self.root = self._add_node(None, text_label, data)
+
+ self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024)
+ self._tree_lines_cached: list[_TreeLine] | None = None
+ self._cursor_node: TreeNode[TreeDataType] | None = None
+
+ @property
+ def cursor_node(self) -> TreeNode[TreeDataType] | None:
+ """TreeNode | Node: The currently selected node, or ``None`` if no selection."""
+ return self._cursor_node
+
+ @property
+ def last_line(self) -> int:
+ """int: the index of the last line."""
+ return len(self._tree_lines) - 1
+
+ def process_label(self, label: TextType):
+ """Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
+
+ Args:
+ label (TextType): Label.
+
+ Returns:
+ Text: A Rich Text object.
+ """
+ if isinstance(label, str):
+ text_label = Text.from_markup(label)
+ else:
+ text_label = label
+ first_line = text_label.split()[0]
+ return first_line
+
+ def _add_node(
+ self,
+ parent: TreeNode[TreeDataType] | None,
+ label: Text,
+ data: TreeDataType | None,
+ expand: bool = False,
+ ) -> TreeNode[TreeDataType]:
+ node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand)
+ self._nodes[node._id] = node
+ self._updates += 1
+ return node
+
+ def render_label(
+ self, node: TreeNode[TreeDataType], base_style: Style, style: Style
+ ) -> Text:
+ """Render a label for the given node. Override this to modify how labels are rendered.
+
+ Args:
+ node (TreeNode[TreeDataType]): A tree node.
+ base_style (Style): The base style of the widget.
+ style (Style): The additional style for the label.
+
+ Returns:
+ Text: A Rich Text object containing the label.
+ """
+ node_label = node._label.copy()
+ node_label.stylize(style)
+
+ if node._allow_expand:
+ prefix = (
+ "▼ " if node.is_expanded else "▶ ",
+ base_style + TOGGLE_STYLE,
+ )
+ else:
+ prefix = ("", base_style)
+
+ text = Text.assemble(prefix, node_label)
+ return text
+
+ def get_label_width(self, node: TreeNode[TreeDataType]) -> int:
+ """Get the width of the nodes label.
+
+ The default behavior is to call `render_node` and return the cell length. This method may be
+ overridden in a sub-class if it can be done more efficiently.
+
+ Args:
+ node (TreeNode[TreeDataType]): A node.
+
+ Returns:
+ int: Width in cells.
+ """
+ label = self.render_label(node, NULL_STYLE, NULL_STYLE)
+ return label.cell_len
+
+ def clear(self) -> None:
+ """Clear all nodes under root."""
+ self._tree_lines_cached = None
+ self._current_id = 0
+ root_label = self.root._label
+ root_data = self.root.data
+ self.root = TreeNode(
+ self,
+ None,
+ self._new_id(),
+ root_label,
+ root_data,
+ expanded=True,
+ )
+ self._updates += 1
+ self.refresh()
+
+ def select_node(self, node: TreeNode | None) -> None:
+ """Move the cursor to the given node, or reset cursor.
+
+ Args:
+ node (TreeNode | None): A tree node, or None to reset cursor.
+ """
+ self.cursor_line = -1 if node is None else node._line
+
+ def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None:
+ """Get the node for a given line.
+
+ Args:
+ line_no (int): A line number.
+
+ Returns:
+ TreeNode[TreeDataType] | None: A tree node, or ``None`` if there is no node at that line.
+ """
+ try:
+ line = self._tree_lines[line_no]
+ except IndexError:
+ return None
+ else:
+ return line.node
+
+ def validate_cursor_line(self, value: int) -> int:
+ """Prevent cursor line from going outside of range."""
+ return clamp(value, 0, len(self._tree_lines) - 1)
+
+ def validate_guide_depth(self, value: int) -> int:
+ """Restrict guide depth to reasonable range."""
+ return clamp(value, 2, 10)
+
+ def _invalidate(self) -> None:
+ """Invalidate caches."""
+ self._line_cache.clear()
+ self._tree_lines_cached = None
+ self._updates += 1
+ self.root._reset()
+ self.refresh(layout=True)
+
+ def _on_mouse_move(self, event: events.MouseMove):
+ meta = event.style.meta
+ if meta and "line" in meta:
+ self.hover_line = meta["line"]
+ else:
+ self.hover_line = -1
+
+ def _new_id(self) -> NodeID:
+ """Create a new node ID.
+
+ Returns:
+ NodeID: A unique node ID.
+ """
+ id = self._current_id
+ self._current_id += 1
+ return NodeID(id)
+
+ def _get_node(self, line: int) -> TreeNode[TreeDataType] | None:
+ try:
+ tree_line = self._tree_lines[line]
+ except IndexError:
+ return None
+ else:
+ return tree_line.node
+
+ def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None:
+ previous_node = self._get_node(previous_hover_line)
+ if previous_node is not None:
+ self._refresh_node(previous_node)
+ previous_node._hover = False
+
+ node = self._get_node(hover_line)
+ if node is not None:
+ self._refresh_node(node)
+ node._hover = True
+
+ def watch_cursor_line(self, previous_line: int, line: int) -> None:
+ previous_node = self._get_node(previous_line)
+ if previous_node is not None:
+ self._refresh_node(previous_node)
+ previous_node._selected = False
+ self._cursor_node = None
+
+ node = self._get_node(line)
+ if node is not None:
+ self._refresh_node(node)
+ node._selected = True
+ self._cursor_node = node
+
+ def watch_guide_depth(self, guide_depth: int) -> None:
+ self._invalidate()
+
+ def watch_show_root(self, show_root: bool) -> None:
+ self.cursor_line = -1
+ self._invalidate()
+
+ def scroll_to_line(self, line: int) -> None:
+ """Scroll to the given line.
+
+ Args:
+ line (int): A line number.
+ """
+ self.scroll_to_region(Region(0, line, self.size.width, 1))
+
+ def scroll_to_node(self, node: TreeNode) -> None:
+ """Scroll to the given node.
+
+ Args:
+ node (TreeNode): Node to scroll in to view.
+ """
+ line = node._line
+ if line != -1:
+ self.scroll_to_line(line)
+
+ def refresh_line(self, line: int) -> None:
+ """Refresh (repaint) a given line in the tree.
+
+ Args:
+ line (int): Line number.
+ """
+ region = Region(0, line - self.scroll_offset.y, self.size.width, 1)
+ self.refresh(region)
+
+ def _refresh_node_line(self, line: int) -> None:
+ node = self._get_node(line)
+ if node is not None:
+ self._refresh_node(node)
+
+ def _refresh_node(self, node: TreeNode[TreeDataType]) -> None:
+ """Refresh a node and all its children.
+
+ Args:
+ node (TreeNode[TreeDataType]): A tree node.
+ """
+ scroll_y = self.scroll_offset.y
+ height = self.size.height
+ visible_lines = self._tree_lines[scroll_y : scroll_y + height]
+ for line_no, line in enumerate(visible_lines, scroll_y):
+ if node in line.path:
+ self.refresh_line(line_no)
+
+ @property
+ def _tree_lines(self) -> list[_TreeLine]:
+ if self._tree_lines_cached is None:
+ self._build()
+ assert self._tree_lines_cached is not None
+ return self._tree_lines_cached
+
+ def _build(self) -> None:
+ """Builds the tree by traversing nodes, and creating tree lines."""
+
+ TreeLine = _TreeLine
+ lines: list[_TreeLine] = []
+ add_line = lines.append
+
+ root = self.root
+
+ def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None:
+ child_path = [*path, node]
+ node._line = len(lines)
+ add_line(TreeLine(child_path, last))
+ if node._expanded:
+ for last, child in loop_last(node._children):
+ add_node(child_path, child, last)
+
+ if self.show_root:
+ add_node([], root, True)
+ else:
+ for node in self.root._children:
+ add_node([], node, True)
+ self._tree_lines_cached = lines
+
+ guide_depth = self.guide_depth
+ show_root = self.show_root
+ get_label_width = self.get_label_width
+
+ def get_line_width(line: _TreeLine) -> int:
+ return get_label_width(line.node) + line._get_guide_width(
+ guide_depth, show_root
+ )
+
+ if lines:
+ width = max([get_line_width(line) for line in lines])
+ else:
+ width = self.size.width
+
+ self.virtual_size = Size(width, len(lines))
+ if self.cursor_line != -1:
+ if self.cursor_node is not None:
+ self.cursor_line = self.cursor_node._line
+ if self.cursor_line >= len(lines):
+ self.cursor_line = -1
+ self.refresh()
+
+ def render_line(self, y: int) -> list[Segment]:
+ width = self.size.width
+ scroll_x, scroll_y = self.scroll_offset
+ style = self.rich_style
+ return self._render_line(
+ y + scroll_y,
+ scroll_x,
+ scroll_x + width,
+ style,
+ )
+
+ def _render_line(
+ self, y: int, x1: int, x2: int, base_style: Style
+ ) -> list[Segment]:
+ tree_lines = self._tree_lines
+ width = self.size.width
+
+ if y >= len(tree_lines):
+ return [Segment(" " * width, base_style)]
+
+ line = tree_lines[y]
+
+ is_hover = self.hover_line >= 0 and any(node._hover for node in line.path)
+
+ cache_key = (
+ y,
+ is_hover,
+ width,
+ self._updates,
+ self.has_focus,
+ tuple(node._updates for node in line.path),
+ )
+ if cache_key in self._line_cache:
+ segments = self._line_cache[cache_key]
+ else:
+ base_guide_style = self.get_component_rich_style(
+ "tree--guides", partial=True
+ )
+ guide_hover_style = base_guide_style + self.get_component_rich_style(
+ "tree--guides-hover", partial=True
+ )
+ guide_selected_style = base_guide_style + self.get_component_rich_style(
+ "tree--guides-selected", partial=True
+ )
+
+ hover = self.root._hover
+ selected = self.root._selected and self.has_focus
+
+ def get_guides(style: Style) -> tuple[str, str, str, str]:
+ """Get the guide strings for a given style.
+
+ Args:
+ style (Style): A Style object.
+
+ Returns:
+ tuple[str, str, str, str]: Strings for space, vertical, terminator and cross.
+ """
+ if self.show_guides:
+ lines = self.LINES["default"]
+ if style.bold:
+ lines = self.LINES["bold"]
+ elif style.underline2:
+ lines = self.LINES["double"]
+ else:
+ lines = (" ", " ", " ", " ")
+
+ guide_depth = max(0, self.guide_depth - 2)
+ lines = tuple(
+ f"{vertical}{horizontal * guide_depth} "
+ for vertical, horizontal in lines
+ )
+ return lines
+
+ if is_hover:
+ line_style = self.get_component_rich_style("tree--highlight-line")
+ else:
+ line_style = base_style
+
+ guides = Text(style=line_style)
+ guides_append = guides.append
+
+ guide_style = base_guide_style
+ for node in line.path[1:]:
+ if hover:
+ guide_style = guide_hover_style
+ if selected:
+ guide_style = guide_selected_style
+
+ space, vertical, _, _ = get_guides(guide_style)
+ guide = space if node.is_last else vertical
+ if node != line.path[-1]:
+ guides_append(guide, style=guide_style)
+ hover = hover or node._hover
+ selected = (selected or node._selected) and self.has_focus
+
+ if len(line.path) > 1:
+ _, _, terminator, cross = get_guides(guide_style)
+ if line.last:
+ guides.append(terminator, style=guide_style)
+ else:
+ guides.append(cross, style=guide_style)
+
+ label_style = self.get_component_rich_style("tree--label", partial=True)
+ if self.hover_line == y:
+ label_style += self.get_component_rich_style(
+ "tree--highlight", partial=True
+ )
+ if self.cursor_line == y and self.has_focus:
+ label_style += self.get_component_rich_style(
+ "tree--cursor", partial=False
+ )
+
+ label = self.render_label(line.path[-1], line_style, label_style).copy()
+ label.stylize(Style(meta={"node": line.node._id, "line": y}))
+ guides.append(label)
+
+ segments = list(guides.render(self.app.console))
+ pad_width = max(self.virtual_size.width, width)
+ segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style)
+ self._line_cache[cache_key] = segments
+
+ segments = line_crop(segments, x1, x2, width)
+
+ return segments
+
+ def _on_resize(self, event: events.Resize) -> None:
+ self._line_cache.grow(event.size.height)
+ self._invalidate()
+
+ def _toggle_node(self, node: TreeNode[TreeDataType]) -> None:
+ if not node.allow_expand:
+ return
+ if node.is_expanded:
+ node.collapse()
+ self.post_message_no_wait(self.NodeCollapsed(self, node))
+ else:
+ node.expand()
+ self.post_message_no_wait(self.NodeExpanded(self, node))
+
+ async def _on_click(self, event: events.Click) -> None:
+ meta = event.style.meta
+ if "line" in meta:
+ cursor_line = meta["line"]
+ if meta.get("toggle", False):
+ node = self.get_node_at_line(cursor_line)
+ if node is not None and self.auto_expand:
+ self._toggle_node(node)
+
+ else:
+ self.cursor_line = cursor_line
+ await self.action("select_cursor")
+
+ def _on_styles_updated(self) -> None:
+ self._invalidate()
+
+ def action_cursor_up(self) -> None:
+ if self.cursor_line == -1:
+ self.cursor_line = self.last_line
+ else:
+ self.cursor_line -= 1
+ self.scroll_to_line(self.cursor_line)
+
+ def action_cursor_down(self) -> None:
+ if self.cursor_line == -1:
+ self.cursor_line = 0
+ else:
+ self.cursor_line += 1
+ self.scroll_to_line(self.cursor_line)
+
+ def action_page_down(self) -> None:
+ if self.cursor_line == -1:
+ self.cursor_line = 0
+ self.cursor_line += self.scrollable_content_region.height - 1
+ self.scroll_to_line(self.cursor_line)
+
+ def action_page_up(self) -> None:
+ if self.cursor_line == -1:
+ self.cursor_line = self.last_line
+ self.cursor_line -= self.scrollable_content_region.height - 1
+ self.scroll_to_line(self.cursor_line)
+
+ def action_scroll_home(self) -> None:
+ self.cursor_line = 0
+ self.scroll_to_line(self.cursor_line)
+
+ def action_scroll_end(self) -> None:
+ self.cursor_line = self.last_line
+ self.scroll_to_line(self.cursor_line)
+
+ def action_select_cursor(self) -> None:
+ try:
+ line = self._tree_lines[self.cursor_line]
+ except IndexError:
+ pass
+ else:
+ node = line.path[-1]
+ if self.auto_expand:
+ self._toggle_node(node)
+ self.post_message_no_wait(self.NodeSelected(self, node))
diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py
deleted file mode 100644
index c471e0686..000000000
--- a/src/textual/widgets/_tree_control.py
+++ /dev/null
@@ -1,427 +0,0 @@
-from __future__ import annotations
-
-
-from typing import ClassVar, Generic, Iterator, NewType, TypeVar
-
-import rich.repr
-from rich.console import RenderableType
-from rich.style import Style, NULL_STYLE
-from rich.text import Text, TextType
-from rich.tree import Tree
-
-from ..geometry import Region, Size
-from .. import events
-from ..reactive import Reactive
-from .._types import MessageTarget
-from ..widgets import Static
-from ..message import Message
-from .. import messages
-
-
-NodeID = NewType("NodeID", int)
-
-
-NodeDataType = TypeVar("NodeDataType")
-EventNodeDataType = TypeVar("EventNodeDataType")
-
-
-@rich.repr.auto
-class TreeNode(Generic[NodeDataType]):
- def __init__(
- self,
- parent: TreeNode[NodeDataType] | None,
- node_id: NodeID,
- control: TreeControl,
- tree: Tree,
- label: TextType,
- data: NodeDataType,
- ) -> None:
- self.parent = parent
- self.id = node_id
- self._control = control
- self._tree = tree
- self.label = label
- self.data = data
- self.loaded = False
- self._expanded = False
- self._empty = False
- self._tree.expanded = False
- self.children: list[TreeNode] = []
-
- def __rich_repr__(self) -> rich.repr.Result:
- yield "id", self.id
- yield "label", self.label
- yield "data", self.data
-
- @property
- def control(self) -> TreeControl:
- return self._control
-
- @property
- def empty(self) -> bool:
- return self._empty
-
- @property
- def expanded(self) -> bool:
- return self._expanded
-
- @property
- def is_cursor(self) -> bool:
- return self.control.cursor == self.id and self.control.show_cursor
-
- @property
- def tree(self) -> Tree:
- return self._tree
-
- @property
- def next_node(self) -> TreeNode[NodeDataType] | None:
- """The next node in the tree, or None if at the end."""
-
- if self.expanded and self.children:
- return self.children[0]
- else:
-
- sibling = self.next_sibling
- if sibling is not None:
- return sibling
-
- node = self
- while True:
- if node.parent is None:
- return None
- sibling = node.parent.next_sibling
- if sibling is not None:
- return sibling
- else:
- node = node.parent
-
- @property
- def previous_node(self) -> TreeNode[NodeDataType] | None:
- """The previous node in the tree, or None if at the end."""
-
- sibling = self.previous_sibling
- if sibling is not None:
-
- def last_sibling(node) -> TreeNode[NodeDataType]:
- if node.expanded and node.children:
- return last_sibling(node.children[-1])
- else:
- return (
- node.children[-1] if (node.children and node.expanded) else node
- )
-
- return last_sibling(sibling)
-
- if self.parent is None:
- return None
- return self.parent
-
- @property
- def next_sibling(self) -> TreeNode[NodeDataType] | None:
- """The next sibling, or None if last sibling."""
- if self.parent is None:
- return None
- iter_siblings = iter(self.parent.children)
- try:
- for node in iter_siblings:
- if node is self:
- return next(iter_siblings)
- except StopIteration:
- pass
- return None
-
- @property
- def previous_sibling(self) -> TreeNode[NodeDataType] | None:
- """Previous sibling or None if first sibling."""
- if self.parent is None:
- return None
- iter_siblings = iter(self.parent.children)
- sibling: TreeNode[NodeDataType] | None = None
-
- for node in iter_siblings:
- if node is self:
- return sibling
- sibling = node
- return None
-
- def expand(self, expanded: bool = True) -> None:
- self._expanded = expanded
- self._tree.expanded = expanded
- self._control.refresh(layout=True)
-
- def toggle(self) -> None:
- self.expand(not self._expanded)
-
- def add(self, label: TextType, data: NodeDataType) -> None:
- self._control.add(self.id, label, data=data)
- self._control.refresh(layout=True)
- self._empty = False
-
- def __rich__(self) -> RenderableType:
- return self._control.render_node(self)
-
-
-class TreeControl(Generic[NodeDataType], Static, can_focus=True):
- DEFAULT_CSS = """
- TreeControl {
- color: $text;
- height: auto;
- width: 100%;
- link-style: not underline;
- }
-
- TreeControl > .tree--guides {
- color: $success;
- }
-
- TreeControl > .tree--guides-highlight {
- color: $success;
- text-style: uu;
- }
-
- TreeControl > .tree--guides-cursor {
- color: $secondary;
- text-style: bold;
- }
-
- TreeControl > .tree--labels {
- color: $text;
- }
-
- TreeControl > .tree--cursor {
- background: $secondary;
- color: $text;
- }
-
- """
-
- COMPONENT_CLASSES: ClassVar[set[str]] = {
- "tree--guides",
- "tree--guides-highlight",
- "tree--guides-cursor",
- "tree--labels",
- "tree--cursor",
- }
-
- class NodeSelected(Generic[EventNodeDataType], Message, bubble=False):
- def __init__(
- self, sender: MessageTarget, node: TreeNode[EventNodeDataType]
- ) -> None:
- self.node = node
- super().__init__(sender)
-
- def __init__(
- self,
- label: TextType,
- data: NodeDataType,
- *,
- name: str | None = None,
- id: str | None = None,
- classes: str | None = None,
- ) -> None:
- super().__init__(name=name, id=id, classes=classes)
- self.data = data
-
- self.node_id = NodeID(0)
- self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
- self._tree = Tree(label)
-
- self.root: TreeNode[NodeDataType] = TreeNode(
- None, self.node_id, self, self._tree, label, data
- )
-
- self._tree.label = self.root
- self.nodes[NodeID(self.node_id)] = self.root
-
- self.auto_links = False
-
- hover_node: Reactive[NodeID | None] = Reactive(None)
- cursor: Reactive[NodeID] = Reactive(NodeID(0))
- cursor_line: Reactive[int] = Reactive(0)
- show_cursor: Reactive[bool] = Reactive(False)
-
- def watch_cursor_line(self, value: int) -> None:
- line_region = Region(0, value, self.size.width, 1)
- self.emit_no_wait(messages.ScrollToRegion(self, line_region))
-
- def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
- def get_size(tree: Tree) -> int:
- return 1 + sum(
- get_size(child) if child.expanded else 1 for child in tree.children
- )
-
- size = get_size(self._tree)
- return size
-
- def add(
- self,
- node_id: NodeID,
- label: TextType,
- data: NodeDataType,
- ) -> None:
-
- parent = self.nodes[node_id]
- self.node_id = NodeID(self.node_id + 1)
- child_tree = parent._tree.add(label)
- child_tree.guide_style = self._guide_style
- child_node: TreeNode[NodeDataType] = TreeNode(
- parent, self.node_id, self, child_tree, label, data
- )
- parent.children.append(child_node)
- child_tree.label = child_node
- self.nodes[self.node_id] = child_node
-
- self.refresh(layout=True)
-
- def find_cursor(self) -> int | None:
- """Find the line location for the cursor node."""
-
- node_id = self.cursor
- line = 0
-
- stack: list[Iterator[TreeNode[NodeDataType]]]
- stack = [iter([self.root])]
-
- pop = stack.pop
- push = stack.append
- while stack:
- iter_children = pop()
- try:
- node = next(iter_children)
- except StopIteration:
- continue
- else:
- if node.id == node_id:
- return line
- line += 1
- push(iter_children)
- if node.children and node.expanded:
- push(iter(node.children))
- return None
-
- def render(self) -> RenderableType:
- guide_style = self._guide_style
-
- def update_guide_style(tree: Tree) -> None:
- tree.guide_style = guide_style
- for child in tree.children:
- if child.expanded:
- update_guide_style(child)
-
- update_guide_style(self._tree)
- if self.hover_node is not None:
- hover = self.nodes.get(self.hover_node)
- if hover is not None:
- hover._tree.guide_style = self._highlight_guide_style
- if self.cursor is not None and self.show_cursor:
- cursor = self.nodes.get(self.cursor)
- if cursor is not None:
- cursor._tree.guide_style = self._cursor_guide_style
- return self._tree
-
- def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
- label_style = self.get_component_styles("tree--labels").rich_style
- label = (
- Text(node.label, no_wrap=True, style=label_style, overflow="ellipsis")
- if isinstance(node.label, str)
- else node.label
- )
- if node.id == self.hover_node:
- label.stylize("underline")
- label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id})
- return label
-
- def action_click_label(self, node_id: NodeID) -> None:
- node = self.nodes[node_id]
- self.cursor = node.id
- self.cursor_line = self.find_cursor() or 0
- self.show_cursor = True
- self.post_message_no_wait(self.NodeSelected(self, node))
-
- def on_mount(self) -> None:
- self._tree.guide_style = self._guide_style
-
- @property
- def _guide_style(self) -> Style:
- return self.get_component_rich_style("tree--guides")
-
- @property
- def _highlight_guide_style(self) -> Style:
- return self.get_component_rich_style("tree--guides-highlight")
-
- @property
- def _cursor_guide_style(self) -> Style:
- return self.get_component_rich_style("tree--guides-cursor")
-
- def on_mouse_move(self, event: events.MouseMove) -> None:
- self.hover_node = event.style.meta.get("tree_node")
-
- def key_down(self, event: events.Key) -> None:
- event.stop()
- self.cursor_down()
-
- def key_up(self, event: events.Key) -> None:
- event.stop()
- self.cursor_up()
-
- def key_pagedown(self) -> None:
- assert self.parent is not None
- height = self.container_viewport.height
-
- cursor = self.cursor
- cursor_line = self.cursor_line
- for _ in range(height):
- cursor_node = self.nodes[cursor]
- next_node = cursor_node.next_node
- if next_node is not None:
- cursor_line += 1
- cursor = next_node.id
- self.cursor = cursor
- self.cursor_line = cursor_line
-
- def key_pageup(self) -> None:
- assert self.parent is not None
- height = self.container_viewport.height
- cursor = self.cursor
- cursor_line = self.cursor_line
- for _ in range(height):
- cursor_node = self.nodes[cursor]
- previous_node = cursor_node.previous_node
- if previous_node is not None:
- cursor_line -= 1
- cursor = previous_node.id
- self.cursor = cursor
- self.cursor_line = cursor_line
-
- def key_home(self) -> None:
- self.cursor_line = 0
- self.cursor = NodeID(0)
-
- def key_end(self) -> None:
- self.cursor = self.nodes[NodeID(0)].children[-1].id
- self.cursor_line = self.find_cursor() or 0
-
- def key_enter(self, event: events.Key) -> None:
- cursor_node = self.nodes[self.cursor]
- event.stop()
- self.post_message_no_wait(self.NodeSelected(self, cursor_node))
-
- def cursor_down(self) -> None:
- if not self.show_cursor:
- self.show_cursor = True
- return
- cursor_node = self.nodes[self.cursor]
- next_node = cursor_node.next_node
- if next_node is not None:
- self.cursor_line += 1
- self.cursor = next_node.id
-
- def cursor_up(self) -> None:
- if not self.show_cursor:
- self.show_cursor = True
- return
- cursor_node = self.nodes[self.cursor]
- previous_node = cursor_node.previous_node
- if previous_node is not None:
- self.cursor_line -= 1
- self.cursor = previous_node.id
diff --git a/src/textual/widgets/_tree_node.py b/src/textual/widgets/_tree_node.py
new file mode 100644
index 000000000..e6c57fb61
--- /dev/null
+++ b/src/textual/widgets/_tree_node.py
@@ -0,0 +1 @@
+from ._tree import TreeNode as TreeNode
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 9351467d0..49e630b95 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -6950,6 +6950,162 @@
'''
# ---
+# name: test_tree_example
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TreeApp
+
+
+
+
+
+
+
+
+
+ ▼ Dune
+ └── ▼ Characters
+ ├── Paul
+ ├── Jessica
+ └── Channi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
# name: test_vertical_layout
'''
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index b5f25db51..6816c207c 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -11,6 +11,7 @@ SNAPSHOT_APPS_DIR = Path("./snapshot_apps")
# --- Layout related stuff ---
+
def test_grid_layout_basic(snap_compare):
assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout1.py")
@@ -48,6 +49,7 @@ def test_dock_layout_sidebar(snap_compare):
# When adding a new widget, ideally we should also create a snapshot test
# from these examples which test rendering and simple interactions with it.
+
def test_checkboxes(snap_compare):
"""Tests checkboxes but also acts a regression test for using
width: auto in a Horizontal layout context."""
@@ -102,6 +104,10 @@ def test_fr_units(snap_compare):
assert snap_compare("snapshot_apps/fr_units.py")
+def test_tree_example(snap_compare):
+ assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py")
+
+
# --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs.
# If any of these change, something has likely broken, so snapshot each of them.
@@ -126,5 +132,6 @@ def test_multiple_css(snap_compare):
# --- Other ---
+
def test_key_display(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")