Skip to content

Commit 49002f9

Browse files
committed
v1.0: tag filter — UIList matches by name or tags
The tags field already existed (StringProperty, comma-separated, editable in the details box). This slice surfaces it in the user- facing filter: typing in the funnel at the bottom of the Studio UIList now matches both name AND tags via case-insensitive substring. - STAGE_UL_studios.filter_items() override that sets bitflag_filter _item per row based on whether the row's name or tags contain the current filter_name. use_filter_invert is honored so the standard "exclude matches" toggle works. - matches_filter(needle, item) helper extracted as a module-level function so it's testable without instantiating a UIList (Blender's bpy_struct rejects direct instantiation of UIList subclasses). - tags property description updated to point users at the filter funnel, so the feature is discoverable from the field's tooltip. - 5 tests covering empty-needle / name-substring / tag-substring / combined / case-insensitive cases. How it shows up for users: Studio | tags --------------------------- Hero | wip, client-a Wide | final Detail | wip Studio | (none) Type "wip" in the filter funnel → only Hero and Detail visible. Type "client" → only Hero. Type "final" → only Wide. 66/66 passing on Blender 5.1.0 (17 unit + 49 Blender). Next v1.0 candidates: Studio inheritance (parent_uuid + delta-only capture), or the right-click → Store Property feature for arbitrary RNA paths (Renderset's killer move per the plan §3 v1.0).
1 parent c7717eb commit 49002f9

3 files changed

Lines changed: 107 additions & 1 deletion

File tree

stage/props/studio.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ class Studio(PropertyGroup):
8181

8282
tags: StringProperty(
8383
name="Tags",
84-
description="Comma-separated tags (e.g. 'wip, client-a, final')",
84+
description=(
85+
"Comma-separated tags (e.g. 'wip, client-a, final'). "
86+
"Use the filter funnel at the bottom of the Studio list to "
87+
"filter by tag — substring matches both name and tags."
88+
),
8589
default="",
8690
)
8791

stage/ui/studio_uilist.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,44 @@
66
from ..utils.preview_cache import get_icon_id
77

88

9+
def matches_filter(needle: str, item) -> bool:
10+
"""Case-insensitive substring match against Studio name OR tags.
11+
12+
Empty needle matches everything. Pulled out of the UIList class so it's
13+
testable without instantiating a UIList (which Blender doesn't support).
14+
"""
15+
if not needle:
16+
return True
17+
needle_lower = needle.lower()
18+
return needle_lower in item.name.lower() or needle_lower in item.tags.lower()
19+
20+
921
class STAGE_UL_studios(UIList):
22+
def filter_items(self, context, data, propname):
23+
"""Filter Studios by name OR tags — case-insensitive substring match.
24+
25+
Triggered by the user typing in the filter funnel at the bottom of
26+
the UIList. Standard `name` matching is extended to also check the
27+
Studio's comma-separated `tags` field, so a search for "wip" finds
28+
Studios tagged #wip alongside any whose name contains "wip".
29+
"""
30+
items = getattr(data, propname)
31+
flt_flags = [self.bitflag_filter_item] * len(items)
32+
flt_neworder: list = []
33+
34+
if self.filter_name:
35+
for i, item in enumerate(items):
36+
if not matches_filter(self.filter_name, item):
37+
flt_flags[i] = 0
38+
39+
if self.use_filter_invert:
40+
flt_flags = [
41+
self.bitflag_filter_item if f == 0 else 0
42+
for f in flt_flags
43+
]
44+
45+
return flt_flags, flt_neworder
46+
1047
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
1148
if self.layout_type in {'DEFAULT', 'COMPACT'}:
1249
# List view: color marker + name. Thumbnails were causing UI

tests/blender/test_tags.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Tests for tag-based filtering in the UIList — v1.0."""
2+
3+
from __future__ import annotations
4+
5+
import bpy
6+
7+
from stage.ui.studio_uilist import matches_filter
8+
9+
10+
def _fresh_scene(name: str = "stage_tags_test"):
11+
if name in bpy.data.scenes:
12+
bpy.data.scenes.remove(bpy.data.scenes[name], do_unlink=True)
13+
return bpy.data.scenes.new(name)
14+
15+
16+
def _make_studio(scene, name: str, tags: str = ""):
17+
s = scene.stage_data.studios.add()
18+
s.name = name
19+
s.uuid = f"tag-test-{name}"
20+
s.tags = tags
21+
return s
22+
23+
24+
# --- matches_filter helper --------------------------------------------------
25+
26+
27+
def test_empty_needle_matches_all():
28+
scene = _fresh_scene()
29+
s = _make_studio(scene, "Hero", "")
30+
assert matches_filter("", s) is True
31+
32+
33+
def test_match_by_name_substring():
34+
scene = _fresh_scene()
35+
s = _make_studio(scene, "Hero")
36+
assert matches_filter("hero", s) is True
37+
assert matches_filter("HERO", s) is True # case-insensitive
38+
assert matches_filter("er", s) is True # substring
39+
assert matches_filter("wide", s) is False
40+
41+
42+
def test_match_by_tag_substring():
43+
scene = _fresh_scene()
44+
s = _make_studio(scene, "Hero", "wip, client-a")
45+
assert matches_filter("wip", s) is True
46+
assert matches_filter("client", s) is True
47+
assert matches_filter("final", s) is False
48+
49+
50+
def test_match_combines_name_and_tags():
51+
scene = _fresh_scene()
52+
name_only = _make_studio(scene, "WipShot", "")
53+
tag_only = _make_studio(scene, "Detail", "wip")
54+
neither = _make_studio(scene, "Hero", "final")
55+
56+
assert matches_filter("wip", name_only) is True
57+
assert matches_filter("wip", tag_only) is True
58+
assert matches_filter("wip", neither) is False
59+
60+
61+
def test_match_case_insensitive_on_tags():
62+
scene = _fresh_scene()
63+
s = _make_studio(scene, "Hero", "WIP, Client-A")
64+
assert matches_filter("wip", s) is True
65+
assert matches_filter("client", s) is True

0 commit comments

Comments
 (0)