diff --git a/touchosc/dist/av-live-control.tosc b/touchosc/dist/av-live-control.tosc index ae9fec4..a6c0c6f 100644 Binary files a/touchosc/dist/av-live-control.tosc and b/touchosc/dist/av-live-control.tosc differ diff --git a/touchosc/gen/layout.py b/touchosc/gen/layout.py index a07a7f2..04fdedc 100644 --- a/touchosc/gen/layout.py +++ b/touchosc/gen/layout.py @@ -317,6 +317,62 @@ def seq_page(): return page +# --------------------------------------------------------------------------- +# Page 4 — SECTIONS +# --------------------------------------------------------------------------- + +def sections_page(): + """Page 4: 8 section slots (launch + save), prev/next nav, SC scene feedback. + + The GROUP carries sections.lua and a /scene/state receive message so that + onReceiveOSC fires on the GROUP and can color the slot buttons. + """ + page = s.node( + "GROUP", + [ + s.prop("name", "SECTIONS", "s"), + s.frame(0, 0, *CANVAS), + s.script(_lua("sections.lua")), + ], + messages=[ + s.osc("/scene/state", [s.val()], send=0, receive=1, feedback=1), + ], + ) + ch = page.find("children") + + ch.append(label("SECTIONS", 10, 8, 200, 30)) + + gx = 10 + bw = 138 + gap = 10 + step = bw + gap # 148 px per column + + launch_y, launch_h = 46, 140 + save_y, save_h = 196, 60 + + for i in range(8): + x = gx + i * step + # Launch button: name=slot{i} so sections.lua can findByName("slot"..i) + ch.append(s.node( + "BUTTON", + [s.prop("name", f"slot{i}", "s"), s.frame(x, launch_y, bw, launch_h)], + messages=[s.osc("/scene/launch", [s.consti(i)])], + )) + # Save button + ch.append(s.node( + "BUTTON", + [s.prop("name", f"S{i + 1}", "s"), s.frame(x, save_y, bw, save_h)], + messages=[s.osc("/scene/save", [s.consti(i)])], + )) + + # Nav buttons + nav_y, nav_h = 272, 80 + ch.append(button("< PREV", "/scene/prev", 10, nav_y, 200, nav_h, args=[s.vali()])) + ch.append(button("NEXT >", "/scene/next", 220, nav_y, 200, nav_h, args=[s.vali()])) + + return page + + # --------------------------------------------------------------------------- # Pager + root # --------------------------------------------------------------------------- @@ -335,6 +391,6 @@ def build(): root = s.node( "GROUP", [s.prop("name", "av-live-control", "s"), s.frame(0, 0, *CANVAS)], - children=[pager([live_page(), fx_page(), seq_page()])], + children=[pager([live_page(), fx_page(), seq_page(), sections_page()])], ) return root diff --git a/touchosc/gen/lua/sections.lua b/touchosc/gen/lua/sections.lua new file mode 100644 index 0000000..6c399d0 --- /dev/null +++ b/touchosc/gen/lua/sections.lua @@ -0,0 +1,23 @@ +-- SECTIONS GROUP: color the 8 launch slot buttons from /scene/state. +-- API verified on GrosMac at fidelity gate. +-- /scene/state ... +-- cur=-1 means no active section; fillN=1 means slot N is saved. +function onReceiveOSC(message, connections) + local path = message[1] + if path ~= "/scene/state" then return end + local args = message[2] + local cur = math.floor((args[1] and tonumber(args[1].value)) or -1) + for i = 0, 7 do + local fill = math.floor((args[i + 2] and tonumber(args[i + 2].value)) or 0) + local slot = self:findByName("slot" .. i, true) + if slot then + if i == cur then + slot.color = Color(0.27, 0.8, 0.4, 1) -- current (green) + elseif fill == 1 then + slot.color = Color(0.2, 0.4, 0.8, 1) -- filled (blue) + else + slot.color = Color(0.15, 0.15, 0.15, 1) -- empty (dark) + end + end + end +end diff --git a/touchosc/tests/test_layout.py b/touchosc/tests/test_layout.py index 40f3f3d..1811e8e 100644 --- a/touchosc/tests/test_layout.py +++ b/touchosc/tests/test_layout.py @@ -23,7 +23,7 @@ def _addr_list(el): # Root / pager # --------------------------------------------------------------------------- -def test_root_has_pager_with_three_pages(tmp_path): +def test_root_has_pager_with_four_pages(tmp_path): root = layout.build() out = tmp_path / "full.tosc" schema.write_tosc(root, str(out)) @@ -31,7 +31,7 @@ def test_root_has_pager_with_three_pages(tmp_path): pager = parsed.find(".//node[@type='PAGER']") assert pager is not None, "PAGER node not found" pages = pager.findall("children/node[@type='GROUP']") - assert len(pages) == 3, f"expected 3 pages, got {len(pages)}" + assert len(pages) == 4, f"expected 4 pages, got {len(pages)}" def test_sixteen_patterns(): @@ -322,3 +322,72 @@ def test_live_page_pad_groups_carry_armed_receive(): f"pad GROUP '{grp_name}' must have exactly 1 /armed/* receive msg " f"on the GROUP itself, got {len(armed_msgs)}" ) + + +# --------------------------------------------------------------------------- +# Page 4 — SECTIONS +# --------------------------------------------------------------------------- + +def test_sections_page_launch_buttons(): + """SECTIONS page: 8 /scene/launch messages with CONSTANT/INTEGER args 0..7.""" + page = layout.sections_page() + launch_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/scene/launch": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + launch_args.append((typ.text, conv.text, val.text)) + assert len(launch_args) == 8, f"expected 8 /scene/launch arg partials, got {len(launch_args)}" + for typ, conv, _ in launch_args: + assert typ == "CONSTANT", f"launch arg type must be CONSTANT, got {typ}" + assert conv == "INTEGER", f"launch arg conversion must be INTEGER, got {conv}" + vals = {v for _, _, v in launch_args} + assert vals == {"0", "1", "2", "3", "4", "5", "6", "7"}, ( + f"expected launch values 0..7, got {vals}" + ) + + +def test_sections_page_save_buttons(): + """SECTIONS page: 8 /scene/save messages with CONSTANT/INTEGER args 0..7.""" + page = layout.sections_page() + save_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/scene/save": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + save_args.append((typ.text, conv.text, val.text)) + assert len(save_args) == 8, f"expected 8 /scene/save arg partials, got {len(save_args)}" + for typ, conv, _ in save_args: + assert typ == "CONSTANT", f"save arg type must be CONSTANT, got {typ}" + assert conv == "INTEGER", f"save arg conversion must be INTEGER, got {conv}" + + +def test_sections_page_nav_buttons(): + """SECTIONS page: /scene/prev and /scene/next present.""" + page = layout.sections_page() + addrs = _addrs(page) + assert "/scene/prev" in addrs, "/scene/prev not found on SECTIONS page" + assert "/scene/next" in addrs, "/scene/next not found on SECTIONS page" + + +def test_sections_page_state_receive_on_group(): + """SECTIONS GROUP itself must carry /scene/state receive so sections.lua fires.""" + page = layout.sections_page() + # Direct messages/osc on the top-level GROUP (not descendants) + state_msgs = [ + osc_el for osc_el in page.findall("messages/osc") + if osc_el.get("receive") == "1" + and osc_el.find("path/partial/value") is not None + and osc_el.find("path/partial/value").text == "/scene/state" + ] + assert len(state_msgs) == 1, ( + f"SECTIONS GROUP must have exactly 1 /scene/state receive msg, got {len(state_msgs)}" + )