From 63e1a45ff749bb9497465d5428bab3db140e1fff Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 12 Nov 2026 20:56:39 -0500 Subject: [PATCH] Discover viewed settings in one inherited sidebar menu --- .../view-sidebar-discovery/author.py | 132 +++++++++++++ .../view-sidebar-discovery/control.plan.json | 15 ++ .../view-sidebar-discovery/control_author.py | 58 ++++++ .../view-sidebar-discovery/delivery.md | 9 + .../view-sidebar-discovery/finish.plan.json | 58 ++++++ .../view-sidebar-discovery/finish_author.py | 18 ++ .../view-sidebar-discovery/focus.plan.json | 43 +++++ .../view-sidebar-discovery/focus_author.py | 21 +++ .../view-sidebar-discovery/hosts.plan.json | 174 ++++++++++++++++++ .../view-sidebar-discovery/hosts_author.py | 68 +++++++ .../lifecycle.plan.json | 104 +++++++++++ .../lifecycle_author.py | 36 ++++ .../view-sidebar-discovery/move.plan.json | 158 ++++++++++++++++ .../native-control.plan.json | 20 ++ .../native_control_author.py | 35 ++++ src/toad/configured_view.py | 45 ++++- src/toad/forms/form_view.py | 10 + src/toad/screens/comms.py | 18 +- src/toad/screens/session_view.py | 5 - src/toad/session_tracker.py | 1 + src/toad/settings_scope.py | 2 +- src/toad/widgets/context_explorer.py | 4 +- src/toad/widgets/project_panel.py | 10 +- src/toad/widgets/session_thread_panels.py | 11 +- src/toad/widgets/side_bar.py | 116 +++++++++++- src/toad/widgets/thread_comms.py | 15 +- tests/textual/test_sidebar_projection.py | 74 ++++++++ 27 files changed, 1203 insertions(+), 57 deletions(-) create mode 100644 .verification/view-sidebar-discovery/author.py create mode 100644 .verification/view-sidebar-discovery/control.plan.json create mode 100644 .verification/view-sidebar-discovery/control_author.py create mode 100644 .verification/view-sidebar-discovery/delivery.md create mode 100644 .verification/view-sidebar-discovery/finish.plan.json create mode 100644 .verification/view-sidebar-discovery/finish_author.py create mode 100644 .verification/view-sidebar-discovery/focus.plan.json create mode 100644 .verification/view-sidebar-discovery/focus_author.py create mode 100644 .verification/view-sidebar-discovery/hosts.plan.json create mode 100644 .verification/view-sidebar-discovery/hosts_author.py create mode 100644 .verification/view-sidebar-discovery/lifecycle.plan.json create mode 100644 .verification/view-sidebar-discovery/lifecycle_author.py create mode 100644 .verification/view-sidebar-discovery/move.plan.json create mode 100644 .verification/view-sidebar-discovery/native-control.plan.json create mode 100644 .verification/view-sidebar-discovery/native_control_author.py diff --git a/.verification/view-sidebar-discovery/author.py b/.verification/view-sidebar-discovery/author.py new file mode 100644 index 000000000..5da73b000 --- /dev/null +++ b/.verification/view-sidebar-discovery/author.py @@ -0,0 +1,132 @@ +import json +from pathlib import Path +root=Path.cwd(); ops=[] +def patch(f,t,o,n): ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def insert(f,t,s): ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +def imp(f,s): ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source=s)) +f='src/toad/widgets/side_bar.py' +insert(f,'ThreadSidebar','''class PanelPage(FillHeight, containers.Vertical): + """The original panel and its controls, retained while another entry is selected.""" + + +class PanelPicker(OneRow, FillWidth, widgets.Select): + """One compact selector over the entries supplied by the viewed object.""" + + +class PanelMenu(FillHeight, containers.Vertical): + """Discover once from the owner; mount each original panel only when selected.""" + + def __init__(self, entries, navigation: SidebarState): + super().__init__() + self.entries = tuple(entries) + self.navigation = navigation + selected = next((entry for entry in self.entries + if entry.title == navigation.panel_title), self.entries[0]) + self.picker = PanelPicker(((entry.title, entry) for entry in self.entries), + value=selected, allow_blank=False, compact=True) + self.body = PanelPage() + self.pages: dict[SideBar.Panel, PanelPage] = {} + + def compose(self): + yield self.picker + yield self.body + + @on(events.Mount) + async def open_selection(self): + await self.show_panel(self.picker.value) + + @on(widgets.Select.Changed) + async def selection_changed(self, event): + if event.select is self.picker and event.value is not widgets.Select.NULL: + event.stop() + await self.show_panel(event.value) + + async def show_panel(self, entry): + for panel, page in self.pages.items(): + if page.display and panel is not entry: + panel.widget.finish() + page.display = panel is entry + panel.widget.sidebar_visibility_changed() + if entry not in self.pages: + children = (() if entry.header_control is None else (entry.header_control,)) + page = PanelPage(*children, entry.widget, id=entry.id) + self.pages[entry] = page + await self.body.mount(page) + self.navigation.panel_title = entry.title + entry.widget.sidebar_visibility_changed() + + def activate(self, widget): + self.picker.value = next(entry for entry in self.entries if entry.widget is widget) + + def finish(self): + for entry in self.pages: + entry.widget.finish() + + +''') +insert(f,'SidebarPanelWidget.sidebar_visibility_changed',''' def finish(self) -> None: + """Passive panels have no pending edit to submit.""" +''') +patch(f,'ThreadSidebar.panel_entries',''' yield self.query_ancestor(ViewSettingsScope).configuration_panel''',''' yield from self.query_ancestor(ViewSettingsScope).configuration_panels''') +insert(f,'ThreadSidebar.__init__',''' def _compose_panels(self): + yield PanelMenu(self.panel_entries(), self.navigation) + + def reveal_panel(self, widget): + self.query_one(PanelMenu).activate(widget) +''') +f='src/toad/session_tracker.py' +patch(f,'SidebarState',''' panel_scroll_y: float = 0''',''' panel_scroll_y: float = 0 + panel_title: str = ""''') +f='src/toad/settings_scope.py' +patch(f,'ViewSettingsScope.configuration_panel',''' def configuration_panel(self): ...''',''' def configuration_panels(self): ...''') +f='src/toad/forms/form_view.py' +insert(f,'FieldView.of',''' @classmethod + def from_field(cls, field: DeclaredField): + """The field provider supplies the declaration used to select its presentation.""" + declaration = field.declaration + parameter = ParameterInfo.create( + name=declaration.name, param_type=field.annotation, + current_value=field.value, default_value=declaration.default_value, + description=declaration.description, is_required=declaration.is_required) + return cls.showing(type(parameter).__mro__)(field) +''') +patch(f,'FieldView.of','''return [cls.showing(type(parameter).__mro__)(field)''','''return [cls.from_field(field)''') +f='src/toad/configured_view.py' +patch(f,'ObjectProperties','''class ObjectProperties(ObjectFormView, SidebarPanelWidget): + """The sidebar edits the view's original state directly."""''','''class ObjectProperties(FillHeight, SidebarPanelWidget): + """One declared settings entry; its form is built when that entry is opened.""" + + def __init__(self, field): + super().__init__() + self.field = field + + def compose(self): + self.form = ObjectFormView(self.field.state, [ObjectFieldView.from_field(self.field)]) + yield self.form + + def finish(self): + self.form.finish()''') +patch(f,'ObjectConfigurationDialog',''' def __init__(self, state): + super().__init__() + self.form = ObjectFormView.whole(state)''',''' def __init__(self, panels): + super().__init__() + self.form = PanelMenu(panels, SidebarState())''') +patch(f,'ConfiguredView.configuration_panel',''' def configuration_panel(self): + return SideBar.Panel("Properties", ObjectProperties.whole(self.configuration_state), + flex=True, collapsed=True)''',''' def configuration_panels(self): + state = self.configuration_state + for path in state.direct_parameter_paths(): + if state.shows(path.value): + field = state.field(path.value) + yield SideBar.Panel(field.label, ObjectProperties(field), + id=f"settings-{path.value}", flex=True)''') +patch(f,'ConfiguredView.open_configuration','''ObjectConfigurationDialog(self.configuration_state)''','''ObjectConfigurationDialog(self.configuration_panels)''') +imp(f,'from toad.native_style import FillHeight') +imp(f,'from toad.forms.object_configuration import ObjectFieldView') +imp(f,'from toad.widgets.side_bar import PanelMenu') +f='src/toad/screens/comms.py' +s=Path(f).read_text(); start=s.index(' def make_thread_sidebar(self)'); end=s.index(' def compose(self)',start) +patch(f,'CommsScreen.make_thread_sidebar',s[start:end].rstrip(),'') +f='src/toad/widgets/session_thread_panels.py' +patch(f,'PlanSessionPanel.update',''' sidebar.query_one(f"#{self.declared_name}-panel", SideBarCollapsible).collapsed = False''',''' sidebar.reveal_panel(plan)''') +Path('.verification/view-sidebar-discovery/move.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'selected-object-panel-menu','operations':ops}]},indent=2)) diff --git a/.verification/view-sidebar-discovery/control.plan.json b/.verification/view-sidebar-discovery/control.plan.json new file mode 100644 index 000000000..4a0d1c312 --- /dev/null +++ b/.verification/view-sidebar-discovery/control.plan.json @@ -0,0 +1,15 @@ +{ + "recipes": [ + { + "recipe_id": "selected-view-dropdown-control", + "operations": [ + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "FrameApp", + "source": "async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspace):\n from toad.configured_view import ObjectProperties\n from toad.forms.object_configuration import ObjectValueView\n from toad.preferences import SETTINGS\n from toad.widgets.side_bar import PanelMenu\n from toad.widgets.thread_comms import ThreadCommsSidebar\n\n workspace.comms.channels.create_tag('settings')\n preview = workspace.project / 'example.txt'\n preview.write_text('A viewed file\\n')\n async with workspace.app(size=(140, 45)) as ui:\n app, pilot = ui.textual\n original = ui.selected_tab\n channel = await ui.open_channel('#settings', actor=workspace.viewer)\n view = app.workspace_sessions.require(channel)\n bar = await ui.revealed(view.thread_sidebar)\n menu = bar.query_one(PanelMenu)\n assert not bar.query(ThreadCommsSidebar)\n assert menu.entries and all(isinstance(entry.widget, ObjectProperties) for entry in menu.entries)\n assert len(menu.pages) == 1\n state = view.configuration_state\n assert state.identity == app.session_navigation.get(channel).identity\n await ui.until(lambda: bool(menu.query(ObjectValueView)), why='selected settings fields')\n assert 'ui.column_width' in {part.field.path.value for part in menu.query(ObjectValueView)}\n assert menu.region.width and menu.query_one(ObjectValueView).region.height\n ui_entry = menu.picker.value\n other = next(entry for entry in menu.entries if entry is not ui_entry)\n menu.picker.value = other\n await ui.until(lambda: menu.navigation.panel_title == other.title, why='dropdown selection')\n menu.picker.value = ui_entry\n await ui.until(lambda: menu.navigation.panel_title == ui_entry.title, why='returning to settings')\n app.saved_settings.set(SETTINGS.ui.column_width, 83)\n assert state.fields.ui.column_width == 83\n state.field('ui.column_width').edit(91)\n app.saved_settings.set(SETTINGS.ui.column_width, 85)\n assert state.fields.ui.column_width == 91\n state.reset_parameter('ui.column_width')\n assert state.fields.ui.column_width == 85\n file_mode = await ui.open_preview(preview)\n file_view = app.workspace_sessions.require(file_mode)\n file_bar = await ui.revealed(file_view.thread_sidebar)\n file_menu = file_bar.query_one(PanelMenu)\n assert file_view.configuration_state.identity == app.session_navigation.get(file_mode).identity\n assert file_view.configuration_state is not state\n assert not file_bar.query(ThreadCommsSidebar)\n assert len(file_menu.entries) < len(menu.entries)\n await ui.select_tab(channel)\n assert view.thread_sidebar is bar and bar.query_one(PanelMenu) is menu\n assert menu.picker.value is ui_entry\n await ui.select_tab(original)\n\n\n" + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/control_author.py b/.verification/view-sidebar-discovery/control_author.py new file mode 100644 index 000000000..e32f04046 --- /dev/null +++ b/.verification/view-sidebar-discovery/control_author.py @@ -0,0 +1,58 @@ +import json +from pathlib import Path +p=Path('tests/textual/test_sidebar_projection.py').resolve() +s='''async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspace): + from toad.configured_view import ObjectProperties + from toad.forms.object_configuration import ObjectValueView + from toad.preferences import SETTINGS + from toad.widgets.side_bar import PanelMenu + from toad.widgets.thread_comms import ThreadCommsSidebar + + workspace.comms.channels.create_tag('settings') + preview = workspace.project / 'example.txt' + preview.write_text('A viewed file\\n') + async with workspace.app(size=(140, 45)) as ui: + app, pilot = ui.textual + original = ui.selected_tab + channel = await ui.open_channel('#settings', actor=workspace.viewer) + view = app.workspace_sessions.require(channel) + bar = await ui.revealed(view.thread_sidebar) + menu = bar.query_one(PanelMenu) + assert not bar.query(ThreadCommsSidebar) + assert menu.entries and all(isinstance(entry.widget, ObjectProperties) for entry in menu.entries) + assert len(menu.pages) == 1 + state = view.configuration_state + assert state.identity == app.session_navigation.get(channel).identity + await ui.until(lambda: bool(menu.query(ObjectValueView)), why='selected settings fields') + assert 'ui.column_width' in {part.field.path.value for part in menu.query(ObjectValueView)} + assert menu.region.width and menu.query_one(ObjectValueView).region.height + ui_entry = menu.picker.value + other = next(entry for entry in menu.entries if entry is not ui_entry) + menu.picker.value = other + await ui.until(lambda: menu.navigation.panel_title == other.title, why='dropdown selection') + menu.picker.value = ui_entry + await ui.until(lambda: menu.navigation.panel_title == ui_entry.title, why='returning to settings') + app.saved_settings.set(SETTINGS.ui.column_width, 83) + assert state.fields.ui.column_width == 83 + state.field('ui.column_width').edit(91) + app.saved_settings.set(SETTINGS.ui.column_width, 85) + assert state.fields.ui.column_width == 91 + state.reset_parameter('ui.column_width') + assert state.fields.ui.column_width == 85 + file_mode = await ui.open_preview(preview) + file_view = app.workspace_sessions.require(file_mode) + file_bar = await ui.revealed(file_view.thread_sidebar) + file_menu = file_bar.query_one(PanelMenu) + assert file_view.configuration_state.identity == app.session_navigation.get(file_mode).identity + assert file_view.configuration_state is not state + assert not file_bar.query(ThreadCommsSidebar) + assert len(file_menu.entries) < len(menu.entries) + await ui.select_tab(channel) + assert view.thread_sidebar is bar and bar.query_one(PanelMenu) is menu + assert menu.picker.value is ui_entry + await ui.select_tab(original) + + +''' +ops=[dict(operation='insert_before_target',file_path=str(p),target_qualname='FrameApp',source=s)] +Path('.verification/view-sidebar-discovery/control.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'selected-view-dropdown-control','operations':ops}]},indent=2)) diff --git a/.verification/view-sidebar-discovery/delivery.md b/.verification/view-sidebar-discovery/delivery.md new file mode 100644 index 000000000..e6d6d1fce --- /dev/null +++ b/.verification/view-sidebar-discovery/delivery.md @@ -0,0 +1,9 @@ +The viewed-object bar now has one dropdown. Its settings entries come from that view's admitted dataclass fields; native agent panels join those entries through their existing declared family. No settings roster or third bar is added. The context menu opens the same entries over the same ObjectState. + +OpenHCS's ConfigWindow binds each selected config to its original ObjectState and builds its editor lazily. This change follows that ownership: one retained view state inherits the live global defaults, and only the selected form is built. Explicit overrides, reset, persistence and field-source metadata stay with ObjectConfiguration and DeclaredField. + +Channel views no longer create Connection/Comms agent panels using the sender's identity. File, channel and native views inherit the same sidebar resource and selection lifecycle. Settings entries retain their actual DottedFieldPath identity, rather than matching display labels. Native role panels retain their nominal widget identity. + +Accordion-specific relationship, context, project and plan readers now use an inherited panel-host capability. A dropdown page and a collapsible both own their visibility and reveal behavior. Hidden dropdown pages stop asking for observations. Plan arrival can select its original panel; keyboard focus goes to the view's own control. + +One focused actual UI control passed in 14.63s: channel and file selection, declaration discovery, lazy forms, nonempty painted fields, dropdown switching, live default inheritance, explicit override/reset, return-to-view retention, shared context editor state, native relationship-panel activation and hidden-panel visibility. Earlier attempts exposed the shell descriptor limit and a redundant abstract override hiding the inherited sidebar factory; both were corrected. No paid provider, broad suite or profiler was used. The final keyboard focus change was source-traced; no extra run added. Physical reopened Toad trial remains Tristan's. diff --git a/.verification/view-sidebar-discovery/finish.plan.json b/.verification/view-sidebar-discovery/finish.plan.json new file mode 100644 index 000000000..b871f93ae --- /dev/null +++ b/.verification/view-sidebar-discovery/finish.plan.json @@ -0,0 +1,58 @@ +{ + "recipes": [ + { + "recipe_id": "migrate-panel-consumers", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/form_view.py", + "target_qualname": "FieldView.of", + "replacements": [ + { + "old_source": "return [cls.from_field(field)", + "new_source": "return [cls.showing(type(parameter).__mro__)(field)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/form_view.py", + "target_qualname": "FieldView.from_field", + "replacements": [ + { + "old_source": "current_value=field.value, default_value=declaration.default_value,", + "new_source": "current_value=None, default_value=declaration.default_value," + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "target_qualname": "PlanSessionPanel.update", + "replacements": [ + { + "old_source": " if plan := sidebar.query_one_optional(Plan):\n plan.entries = entries\n if entries:\n sidebar.reveal_panel(plan)", + "new_source": " if sidebar.panels_loaded:\n plan = next(panel.widget for owner, panel in zip(sidebar._panel_owners, sidebar.panels)\n if owner is self)\n plan.entries = entries\n if entries:\n sidebar.reveal_panel(plan)" + } + ] + }, + { + "operation": "remove_import_bindings", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/comms.py", + "bound_names": [ + "CoordinationStatus", + "RelationshipSort", + "ThreadCommsSidebar" + ] + }, + { + "operation": "remove_import_bindings", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "bound_names": [ + "SideBarCollapsible" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/finish_author.py b/.verification/view-sidebar-discovery/finish_author.py new file mode 100644 index 000000000..535191828 --- /dev/null +++ b/.verification/view-sidebar-discovery/finish_author.py @@ -0,0 +1,18 @@ +import json +from pathlib import Path +root=Path.cwd(); ops=[] +def p(f,t,o,n): ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +p('src/toad/forms/form_view.py','FieldView.of','return [cls.from_field(field)','return [cls.showing(type(parameter).__mro__)(field)') +p('src/toad/forms/form_view.py','FieldView.from_field','current_value=field.value, default_value=declaration.default_value,','current_value=None, default_value=declaration.default_value,') +p('src/toad/widgets/session_thread_panels.py','PlanSessionPanel.update', ''' if plan := sidebar.query_one_optional(Plan): + plan.entries = entries + if entries: + sidebar.reveal_panel(plan)''',''' if sidebar.panels_loaded: + plan = next(panel.widget for owner, panel in zip(sidebar._panel_owners, sidebar.panels) + if owner is self) + plan.entries = entries + if entries: + sidebar.reveal_panel(plan)''') +for f,names in [('src/toad/screens/comms.py',['CoordinationStatus','RelationshipSort','ThreadCommsSidebar']),('src/toad/widgets/session_thread_panels.py',['SideBarCollapsible'])]: + ops.append(dict(operation='remove_import_bindings',file_path=str(root/f),bound_names=names)) +Path('.verification/view-sidebar-discovery/finish.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'migrate-panel-consumers','operations':ops}]},indent=2)) diff --git a/.verification/view-sidebar-discovery/focus.plan.json b/.verification/view-sidebar-discovery/focus.plan.json new file mode 100644 index 000000000..57883ecac --- /dev/null +++ b/.verification/view-sidebar-discovery/focus.plan.json @@ -0,0 +1,43 @@ +{ + "recipes": [ + { + "recipe_id": "inherit-panel-focus-and-delete-old-intent-write", + "operations": [ + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SideBar.toggle", + "source": " def focus_contents(self):\n self.panels_viewport.focus()\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SideBar.toggle", + "replacements": [ + { + "old_source": " if title := self.query_one_optional(\"SideBarCollapsible CollapsibleTitle\"):\n title.focus()", + "new_source": " if self.panels_loaded:\n self.focus_contents()" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "ThreadSidebar.reveal_panel", + "source": " def focus_contents(self):\n self.query_one(PanelPicker).focus()\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "target_qualname": "PlanSessionPanel.update", + "replacements": [ + { + "old_source": " title = self.declared_name.title()\n if entries:\n sidebar.navigation.panels_collapsed[title] = False\n", + "new_source": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/focus_author.py b/.verification/view-sidebar-discovery/focus_author.py new file mode 100644 index 000000000..9b9e0c6e9 --- /dev/null +++ b/.verification/view-sidebar-discovery/focus_author.py @@ -0,0 +1,21 @@ +import json +from pathlib import Path +root=Path.cwd(); ops=[] +def p(f,t,o,n): ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def ins(f,t,s): ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +f='src/toad/widgets/side_bar.py' +ins(f,'SideBar.toggle',''' def focus_contents(self): + self.panels_viewport.focus() +''') +p(f,'SideBar.toggle',''' if title := self.query_one_optional("SideBarCollapsible CollapsibleTitle"): + title.focus()''',''' if self.panels_loaded: + self.focus_contents()''') +ins(f,'ThreadSidebar.reveal_panel',''' def focus_contents(self): + self.query_one(PanelPicker).focus() +''') +f='src/toad/widgets/session_thread_panels.py' +p(f,'PlanSessionPanel.update',''' title = self.declared_name.title() + if entries: + sidebar.navigation.panels_collapsed[title] = False +''','') +Path('.verification/view-sidebar-discovery/focus.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'inherit-panel-focus-and-delete-old-intent-write','operations':ops}]},indent=2)) diff --git a/.verification/view-sidebar-discovery/hosts.plan.json b/.verification/view-sidebar-discovery/hosts.plan.json new file mode 100644 index 000000000..8b29f6184 --- /dev/null +++ b/.verification/view-sidebar-discovery/hosts.plan.json @@ -0,0 +1,174 @@ +{ + "stages": [ + { + "recipes": [ + { + "recipe_id": "panel-hosts-own-visibility", + "operations": [ + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SidebarPanelWidget", + "source": "class SidebarPanelHost(Widget):\n \"\"\"A panel's actual host owns whether its content is presented.\"\"\"\n\n @property\n def panel_visible(self):\n return self.is_attached and all(node.display for node in self.walk_ancestors(with_self=True)\n if isinstance(node, Widget))\n\n @abstractmethod\n def reveal(self): ...\n\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SideBarCollapsible", + "replacements": [ + { + "old_source": "class SideBarCollapsible(\n FillWidth,", + "new_source": "class SideBarCollapsible(\n SidebarPanelHost,\n FillWidth," + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SideBarCollapsible.__init__", + "source": " @property\n def panel_visible(self):\n return super().panel_visible and not self.collapsed\n\n def reveal(self):\n self.collapsed = False\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SidebarVisibilityObserver.scroll_container", + "source": " @property\n def panel_visible(self):\n return self.query_ancestor(SidebarPanelHost).panel_visible\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelPage", + "replacements": [ + { + "old_source": "class PanelPage(FillHeight, containers.Vertical):\n \"\"\"The original panel and its controls, retained while another entry is selected.\"\"\"", + "new_source": "class PanelBody(FillHeight, containers.Vertical):\n \"\"\"The space left below the selected-view menu.\"\"\"\n\n\nclass PanelPage(SidebarPanelHost, PanelBody):\n \"\"\"The original panel and its controls, retained while another entry is selected.\"\"\"\n\n def reveal(self):\n menu = self.query_ancestor(PanelMenu)\n menu.picker.value = next(entry for entry, page in menu.pages.items() if page is self)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.__init__", + "replacements": [ + { + "old_source": "self.body = PanelPage()", + "new_source": "self.body = PanelBody()" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "RelationshipSort.order_writer", + "replacements": [ + { + "old_source": "self.query_ancestor(SideBarCollapsible)", + "new_source": "self.query_ancestor(SidebarPanelHost)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar._refresh_owner", + "replacements": [ + { + "old_source": "self.query_ancestor(SideBarCollapsible)", + "new_source": "self.query_ancestor(SidebarPanelHost)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar._visible", + "replacements": [ + { + "old_source": "and not self.query_ancestor(SideBarCollapsible).collapsed", + "new_source": "and self.panel_visible" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar._presentation_visible", + "replacements": [ + { + "old_source": "and self.query_ancestor(SideBar).presentation_visible", + "new_source": "and self.query_ancestor(SideBar).presentation_visible\n and self.panel_visible" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar.accepts_observation", + "replacements": [ + { + "old_source": "return self.model_state.serves(identity.root_path)", + "new_source": "return self.panel_visible and self.model_state.serves(identity.root_path)" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "import_source": "from toad.widgets.side_bar import SidebarPanelHost" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/context_explorer.py", + "target_qualname": "ContextExplorer.presentation_visible", + "replacements": [ + { + "old_source": "and not self.query_ancestor(SideBarCollapsible).collapsed", + "new_source": "and self.panel_visible" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/project_panel.py", + "target_qualname": "ProjectPanel._ensure_tree", + "replacements": [ + { + "old_source": " from toad.widgets.side_bar import SideBar, SideBarCollapsible\n\n if not self.is_attached:\n return\n sidebar = next((node for node in self.ancestors if isinstance(node, SideBar)), None)\n panel = next((node for node in self.ancestors if isinstance(node, SideBarCollapsible)), None)\n if sidebar is None or panel is None or sidebar.collapsed or panel.collapsed:\n return", + "new_source": " if not self.is_attached or not self.query_ancestor(SidebarPanelHost).panel_visible:\n return" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/project_panel.py", + "import_source": "from toad.widgets.side_bar import SidebarPanelHost" + } + ] + } + ] + }, + { + "recipes": [ + { + "recipe_id": "drop-obsolete-frame-imports", + "operations": [ + { + "operation": "remove_import_bindings", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "bound_names": [ + "SideBarCollapsible" + ] + }, + { + "operation": "remove_import_bindings", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/context_explorer.py", + "bound_names": [ + "SideBarCollapsible" + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/hosts_author.py b/.verification/view-sidebar-discovery/hosts_author.py new file mode 100644 index 000000000..4db1691c4 --- /dev/null +++ b/.verification/view-sidebar-discovery/hosts_author.py @@ -0,0 +1,68 @@ +import json +from pathlib import Path +root=Path.cwd(); ops=[] +def p(f,t,o,n): ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def ins(f,t,s): ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +f='src/toad/widgets/side_bar.py' +ins(f,'SidebarPanelWidget','''class SidebarPanelHost(Widget): + """A panel's actual host owns whether its content is presented.""" + + @property + def panel_visible(self): + return self.is_attached and all(node.display for node in self.walk_ancestors(with_self=True) + if isinstance(node, Widget)) + + @abstractmethod + def reveal(self): ... + + +''') +p(f,'SideBarCollapsible','''class SideBarCollapsible( + FillWidth,''','''class SideBarCollapsible( + SidebarPanelHost, + FillWidth,''') +ins(f,'SideBarCollapsible.__init__',''' @property + def panel_visible(self): + return super().panel_visible and not self.collapsed + + def reveal(self): + self.collapsed = False +''') +ins(f,'SidebarVisibilityObserver.scroll_container',''' @property + def panel_visible(self): + return self.query_ancestor(SidebarPanelHost).panel_visible +''') +p(f,'PanelPage','''class PanelPage(FillHeight, containers.Vertical): + """The original panel and its controls, retained while another entry is selected."""''','''class PanelBody(FillHeight, containers.Vertical): + """The space left below the selected-view menu.""" + + +class PanelPage(SidebarPanelHost, PanelBody): + """The original panel and its controls, retained while another entry is selected.""" + + def reveal(self): + menu = self.query_ancestor(PanelMenu) + menu.picker.value = next(entry for entry, page in menu.pages.items() if page is self)''') +p(f,'PanelMenu.__init__','self.body = PanelPage()','self.body = PanelBody()') +f='src/toad/widgets/thread_comms.py' +for target in ['RelationshipSort.order_writer','ThreadCommsSidebar._refresh_owner']: + src=Path(f).read_text(); # exact expressions are unique inside each native target + p(f,target,'self.query_ancestor(SideBarCollapsible)','self.query_ancestor(SidebarPanelHost)') +p(f,'ThreadCommsSidebar._visible','and not self.query_ancestor(SideBarCollapsible).collapsed','and self.panel_visible') +p(f,'ThreadCommsSidebar._presentation_visible','and self.query_ancestor(SideBar).presentation_visible','and self.query_ancestor(SideBar).presentation_visible\n and self.panel_visible') +p(f,'ThreadCommsSidebar.accepts_observation','return self.model_state.serves(identity.root_path)','return self.panel_visible and self.model_state.serves(identity.root_path)') +ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source='from toad.widgets.side_bar import SidebarPanelHost')) +f='src/toad/widgets/context_explorer.py' +p(f,'ContextExplorer.presentation_visible','and not self.query_ancestor(SideBarCollapsible).collapsed','and self.panel_visible') +f='src/toad/widgets/project_panel.py' +p(f,'ProjectPanel._ensure_tree',''' from toad.widgets.side_bar import SideBar, SideBarCollapsible + + if not self.is_attached: + return + sidebar = next((node for node in self.ancestors if isinstance(node, SideBar)), None) + panel = next((node for node in self.ancestors if isinstance(node, SideBarCollapsible)), None) + if sidebar is None or panel is None or sidebar.collapsed or panel.collapsed: + return''',''' if not self.is_attached or not self.query_ancestor(SidebarPanelHost).panel_visible: + return''') +ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source='from toad.widgets.side_bar import SidebarPanelHost')) +Path('.verification/view-sidebar-discovery/hosts.plan.json').write_text(json.dumps({'stages':[{'recipes':[{'recipe_id':'panel-hosts-own-visibility','operations':ops}]},{'recipes':[{'recipe_id':'drop-obsolete-frame-imports','operations':[dict(operation='remove_import_bindings',file_path=str(root/f),bound_names=['SideBarCollapsible']) for f in ['src/toad/widgets/thread_comms.py','src/toad/widgets/context_explorer.py']]}]}]},indent=2)) diff --git a/.verification/view-sidebar-discovery/lifecycle.plan.json b/.verification/view-sidebar-discovery/lifecycle.plan.json new file mode 100644 index 000000000..6cc82f820 --- /dev/null +++ b/.verification/view-sidebar-discovery/lifecycle.plan.json @@ -0,0 +1,104 @@ +{ + "recipes": [ + { + "recipe_id": "inherit-original-sidebar-lifecycle", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/session_view.py", + "target_qualname": "SidebarSessionView", + "replacements": [ + { + "old_source": " @cached_property\n def _thread_sidebar_state(self) -> SidebarState:\n return SidebarState()\n\n @abstractmethod\n def make_thread_sidebar(self) -> SidebarT: ...", + "new_source": "" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ConfiguredView.make_thread_sidebar", + "source": " @cached_property\n def _thread_sidebar_state(self):\n return SidebarState()\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ConfiguredView.make_thread_sidebar", + "replacements": [ + { + "old_source": "return ThreadSidebar(state=SidebarState())", + "new_source": "return ThreadSidebar(state=self._thread_sidebar_state)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ObjectProperties.compose", + "source": " @property\n def panel_identity(self):\n return self.field.path\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SidebarPanelWidget.finish", + "source": " @property\n def panel_identity(self):\n \"\"\"The nominal widget role identifies a panel across its lazy realizations.\"\"\"\n return type(self)\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.__init__", + "replacements": [ + { + "old_source": "if entry.title == navigation.panel_title", + "new_source": "if entry.widget.panel_identity == navigation.panel_identity" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.show_panel", + "replacements": [ + { + "old_source": "self.navigation.panel_title = entry.title", + "new_source": "self.navigation.panel_identity = entry.widget.panel_identity" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/session_tracker.py", + "target_qualname": "SidebarState", + "replacements": [ + { + "old_source": "panel_title: str = \"\"", + "new_source": "panel_identity: object = None" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": "menu.navigation.panel_title == other.title", + "new_source": "menu.navigation.panel_identity == other.widget.panel_identity" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": "menu.navigation.panel_title == ui_entry.title", + "new_source": "menu.navigation.panel_identity == ui_entry.widget.panel_identity" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/lifecycle_author.py b/.verification/view-sidebar-discovery/lifecycle_author.py new file mode 100644 index 000000000..fb4180fc0 --- /dev/null +++ b/.verification/view-sidebar-discovery/lifecycle_author.py @@ -0,0 +1,36 @@ +import json +from pathlib import Path +root=Path.cwd(); ops=[] +def p(f,t,o,n): ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def insert(f,t,s): ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +f='src/toad/screens/session_view.py' +p(f,'SidebarSessionView', ''' @cached_property + def _thread_sidebar_state(self) -> SidebarState: + return SidebarState() + + @abstractmethod + def make_thread_sidebar(self) -> SidebarT: ...''','') +f='src/toad/configured_view.py' +insert(f,'ConfiguredView.make_thread_sidebar',''' @cached_property + def _thread_sidebar_state(self): + return SidebarState() +''') +p(f,'ConfiguredView.make_thread_sidebar','return ThreadSidebar(state=SidebarState())','return ThreadSidebar(state=self._thread_sidebar_state)') +insert(f,'ObjectProperties.compose',''' @property + def panel_identity(self): + return self.field.path +''') +f='src/toad/widgets/side_bar.py' +insert(f,'SidebarPanelWidget.finish',''' @property + def panel_identity(self): + """The nominal widget role identifies a panel across its lazy realizations.""" + return type(self) +''') +p(f,'PanelMenu.__init__','if entry.title == navigation.panel_title','if entry.widget.panel_identity == navigation.panel_identity') +p(f,'PanelMenu.show_panel','self.navigation.panel_title = entry.title','self.navigation.panel_identity = entry.widget.panel_identity') +f='src/toad/session_tracker.py' +p(f,'SidebarState','panel_title: str = ""','panel_identity: object = None') +f='tests/textual/test_sidebar_projection.py' +p(f,'test_view_menu_discovers_settings_and_keeps_each_objects_state','menu.navigation.panel_title == other.title','menu.navigation.panel_identity == other.widget.panel_identity') +p(f,'test_view_menu_discovers_settings_and_keeps_each_objects_state','menu.navigation.panel_title == ui_entry.title','menu.navigation.panel_identity == ui_entry.widget.panel_identity') +Path('.verification/view-sidebar-discovery/lifecycle.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'inherit-original-sidebar-lifecycle','operations':ops}]},indent=2)) diff --git a/.verification/view-sidebar-discovery/move.plan.json b/.verification/view-sidebar-discovery/move.plan.json new file mode 100644 index 000000000..c08770fe8 --- /dev/null +++ b/.verification/view-sidebar-discovery/move.plan.json @@ -0,0 +1,158 @@ +{ + "recipes": [ + { + "recipe_id": "selected-object-panel-menu", + "operations": [ + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "ThreadSidebar", + "source": "class PanelPage(FillHeight, containers.Vertical):\n \"\"\"The original panel and its controls, retained while another entry is selected.\"\"\"\n\n\nclass PanelPicker(OneRow, FillWidth, widgets.Select):\n \"\"\"One compact selector over the entries supplied by the viewed object.\"\"\"\n\n\nclass PanelMenu(FillHeight, containers.Vertical):\n \"\"\"Discover once from the owner; mount each original panel only when selected.\"\"\"\n\n def __init__(self, entries, navigation: SidebarState):\n super().__init__()\n self.entries = tuple(entries)\n self.navigation = navigation\n selected = next((entry for entry in self.entries\n if entry.title == navigation.panel_title), self.entries[0])\n self.picker = PanelPicker(((entry.title, entry) for entry in self.entries),\n value=selected, allow_blank=False, compact=True)\n self.body = PanelPage()\n self.pages: dict[SideBar.Panel, PanelPage] = {}\n\n def compose(self):\n yield self.picker\n yield self.body\n\n @on(events.Mount)\n async def open_selection(self):\n await self.show_panel(self.picker.value)\n\n @on(widgets.Select.Changed)\n async def selection_changed(self, event):\n if event.select is self.picker and event.value is not widgets.Select.NULL:\n event.stop()\n await self.show_panel(event.value)\n\n async def show_panel(self, entry):\n for panel, page in self.pages.items():\n if page.display and panel is not entry:\n panel.widget.finish()\n page.display = panel is entry\n panel.widget.sidebar_visibility_changed()\n if entry not in self.pages:\n children = (() if entry.header_control is None else (entry.header_control,))\n page = PanelPage(*children, entry.widget, id=entry.id)\n self.pages[entry] = page\n await self.body.mount(page)\n self.navigation.panel_title = entry.title\n entry.widget.sidebar_visibility_changed()\n\n def activate(self, widget):\n self.picker.value = next(entry for entry in self.entries if entry.widget is widget)\n\n def finish(self):\n for entry in self.pages:\n entry.widget.finish()\n\n\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "SidebarPanelWidget.sidebar_visibility_changed", + "source": " def finish(self) -> None:\n \"\"\"Passive panels have no pending edit to submit.\"\"\"\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "ThreadSidebar.panel_entries", + "replacements": [ + { + "old_source": " yield self.query_ancestor(ViewSettingsScope).configuration_panel", + "new_source": " yield from self.query_ancestor(ViewSettingsScope).configuration_panels" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "ThreadSidebar.__init__", + "source": " def _compose_panels(self):\n yield PanelMenu(self.panel_entries(), self.navigation)\n\n def reveal_panel(self, widget):\n self.query_one(PanelMenu).activate(widget)\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/session_tracker.py", + "target_qualname": "SidebarState", + "replacements": [ + { + "old_source": " panel_scroll_y: float = 0", + "new_source": " panel_scroll_y: float = 0\n panel_title: str = \"\"" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/settings_scope.py", + "target_qualname": "ViewSettingsScope.configuration_panel", + "replacements": [ + { + "old_source": " def configuration_panel(self): ...", + "new_source": " def configuration_panels(self): ..." + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/form_view.py", + "target_qualname": "FieldView.of", + "source": " @classmethod\n def from_field(cls, field: DeclaredField):\n \"\"\"The field provider supplies the declaration used to select its presentation.\"\"\"\n declaration = field.declaration\n parameter = ParameterInfo.create(\n name=declaration.name, param_type=field.annotation,\n current_value=field.value, default_value=declaration.default_value,\n description=declaration.description, is_required=declaration.is_required)\n return cls.showing(type(parameter).__mro__)(field)\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/form_view.py", + "target_qualname": "FieldView.of", + "replacements": [ + { + "old_source": "return [cls.showing(type(parameter).__mro__)(field)", + "new_source": "return [cls.from_field(field)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ObjectProperties", + "replacements": [ + { + "old_source": "class ObjectProperties(ObjectFormView, SidebarPanelWidget):\n \"\"\"The sidebar edits the view's original state directly.\"\"\"", + "new_source": "class ObjectProperties(FillHeight, SidebarPanelWidget):\n \"\"\"One declared settings entry; its form is built when that entry is opened.\"\"\"\n\n def __init__(self, field):\n super().__init__()\n self.field = field\n\n def compose(self):\n self.form = ObjectFormView(self.field.state, [ObjectFieldView.from_field(self.field)])\n yield self.form\n\n def finish(self):\n self.form.finish()" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ObjectConfigurationDialog", + "replacements": [ + { + "old_source": " def __init__(self, state):\n super().__init__()\n self.form = ObjectFormView.whole(state)", + "new_source": " def __init__(self, panels):\n super().__init__()\n self.form = PanelMenu(panels, SidebarState())" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ConfiguredView.configuration_panel", + "replacements": [ + { + "old_source": " def configuration_panel(self):\n return SideBar.Panel(\"Properties\", ObjectProperties.whole(self.configuration_state),\n flex=True, collapsed=True)", + "new_source": " def configuration_panels(self):\n state = self.configuration_state\n for path in state.direct_parameter_paths():\n if state.shows(path.value):\n field = state.field(path.value)\n yield SideBar.Panel(field.label, ObjectProperties(field),\n id=f\"settings-{path.value}\", flex=True)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ConfiguredView.open_configuration", + "replacements": [ + { + "old_source": "ObjectConfigurationDialog(self.configuration_state)", + "new_source": "ObjectConfigurationDialog(self.configuration_panels)" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "import_source": "from toad.native_style import FillHeight" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "import_source": "from toad.forms.object_configuration import ObjectFieldView" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "import_source": "from toad.widgets.side_bar import PanelMenu" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/comms.py", + "target_qualname": "CommsScreen.make_thread_sidebar", + "replacements": [ + { + "old_source": " def make_thread_sidebar(self) -> ThreadSidebar:\n return ThreadSidebar(\n SideBar.Panel(\n \"Connection\",\n CoordinationStatus(),\n id=\"coordination-panel\",\n ),\n SideBar.Panel(\n \"Comms\",\n ThreadCommsSidebar(),\n id=\"thread-comms-panel\",\n header_control=RelationshipSort(),\n ),\n state=self._thread_sidebar_state,\n )", + "new_source": "" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "target_qualname": "PlanSessionPanel.update", + "replacements": [ + { + "old_source": " sidebar.query_one(f\"#{self.declared_name}-panel\", SideBarCollapsible).collapsed = False", + "new_source": " sidebar.reveal_panel(plan)" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/native-control.plan.json b/.verification/view-sidebar-discovery/native-control.plan.json new file mode 100644 index 000000000..72df21656 --- /dev/null +++ b/.verification/view-sidebar-discovery/native-control.plan.json @@ -0,0 +1,20 @@ +{ + "recipes": [ + { + "recipe_id": "exercise-native-panel-and-context-lifecycle", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " await ui.select_tab(original)", + "new_source": " view.open_configuration()\n await ui.step()\n dialog = app.screen\n assert all(entry.widget.field.state is state for entry in dialog.form.entries)\n await ui.until(lambda: bool(dialog.query(ObjectValueView)), why='the shared context editor')\n dialog.dismiss_control()\n await ui.step()\n await ui.select_tab(original)\n native = app.workspace_sessions.require(original)\n workspace.thread('panel-owner')\n await workspace.bind(native, 'panel-owner')\n native_bar = await ui.revealed(native.thread_sidebar)\n native_menu = native_bar.query_one(PanelMenu)\n relationships = next(entry for entry in native_menu.entries\n if isinstance(entry.widget, ThreadCommsSidebar))\n native_menu.picker.value = relationships\n await ui.until(lambda: relationships.widget.is_mounted, why='the native relationship panel')\n assert relationships.widget.panel_visible\n relationships.widget._visible()\n settings_entry = next(entry for entry in native_menu.entries\n if isinstance(entry.widget, ObjectProperties))\n native_menu.picker.value = settings_entry\n await ui.until(lambda: not relationships.widget.panel_visible, why='the hidden native panel')" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/view-sidebar-discovery/native_control_author.py b/.verification/view-sidebar-discovery/native_control_author.py new file mode 100644 index 000000000..5c450ab42 --- /dev/null +++ b/.verification/view-sidebar-discovery/native_control_author.py @@ -0,0 +1,35 @@ +import json +from pathlib import Path +p=Path('tests/textual/test_sidebar_projection.py').resolve() +old=""" await ui.select_tab(original) + + +""" +new=""" view.open_configuration() + await ui.step() + dialog = app.screen + assert all(entry.widget.field.state is state for entry in dialog.form.entries) + await ui.until(lambda: bool(dialog.query(ObjectValueView)), why='the shared context editor') + dialog.dismiss_control() + await ui.step() + await ui.select_tab(original) + native = app.workspace_sessions.require(original) + workspace.thread('panel-owner') + await workspace.bind(native, 'panel-owner') + native_bar = await ui.revealed(native.thread_sidebar) + native_menu = native_bar.query_one(PanelMenu) + relationships = next(entry for entry in native_menu.entries + if isinstance(entry.widget, ThreadCommsSidebar)) + native_menu.picker.value = relationships + await ui.until(lambda: relationships.widget.is_mounted, why='the native relationship panel') + assert relationships.widget.panel_visible + relationships.widget._visible() + settings_entry = next(entry for entry in native_menu.entries + if isinstance(entry.widget, ObjectProperties)) + native_menu.picker.value = settings_entry + await ui.until(lambda: not relationships.widget.panel_visible, why='the hidden native panel') + + +""" +plan={'recipes':[{'recipe_id':'exercise-native-panel-and-context-lifecycle','operations':[dict(operation='patch_target',file_path=str(p),target_qualname='test_view_menu_discovers_settings_and_keeps_each_objects_state',replacements=[dict(old_source=old.rstrip(),new_source=new.rstrip())])]}]} +Path('.verification/view-sidebar-discovery/native-control.plan.json').write_text(json.dumps(plan,indent=2)) diff --git a/src/toad/configured_view.py b/src/toad/configured_view.py index 9647e3692..816b88111 100644 --- a/src/toad/configured_view.py +++ b/src/toad/configured_view.py @@ -2,7 +2,8 @@ from functools import cached_property from textual import events, on from textual.geometry import Region -from toad.forms.object_configuration import ObjectFormView +from toad.forms.object_configuration import ObjectFieldView, ObjectFormView +from toad.native_style import FillHeight from toad.object_configuration import ObjectConfiguration from toad.screens.modal import DismissibleScreen, ModalPanel from toad.forms.presentation import FormModal @@ -11,6 +12,7 @@ from toad.widgets.acting_button import DismissButton from toad.widgets.comms_menu import ActionMenu from toad.widgets.side_bar import ( + PanelMenu, SideBar, SidebarPanelWidget, ThreadSidebar, @@ -20,14 +22,29 @@ -class ObjectProperties(ObjectFormView, SidebarPanelWidget): - """The sidebar edits the view's original state directly.""" +class ObjectProperties(FillHeight, SidebarPanelWidget): + """One declared settings entry; its form is built when that entry is opened.""" + + def __init__(self, field): + super().__init__() + self.field = field + + @property + def panel_identity(self): + return self.field.path + + def compose(self): + self.form = ObjectFormView(self.field.state, [ObjectFieldView.from_field(self.field)]) + yield self.form + + def finish(self): + self.form.finish() class ObjectConfigurationDialog(FormModal[None], DismissibleScreen[None]): - def __init__(self, state): + def __init__(self, panels): super().__init__() - self.form = ObjectFormView.whole(state) + self.form = PanelMenu(panels, SidebarState()) def compose(self): with ModalPanel(): @@ -47,12 +64,20 @@ def configuration_state(self) -> ObjectConfiguration: self.app.saved_settings.path().parent, self) @property - def configuration_panel(self): - return SideBar.Panel("Properties", ObjectProperties.whole(self.configuration_state), - flex=True, collapsed=True) + def configuration_panels(self): + state = self.configuration_state + for path in state.direct_parameter_paths(): + if state.shows(path.value): + field = state.field(path.value) + yield SideBar.Panel(field.label, ObjectProperties(field), + id=f"settings-{path.value}", flex=True) + + @cached_property + def _thread_sidebar_state(self): + return SidebarState() def make_thread_sidebar(self): - return ThreadSidebar(state=SidebarState()) + return ThreadSidebar(state=self._thread_sidebar_state) @cached_property def thread_sidebar(self): @@ -76,4 +101,4 @@ def configuration_menu(self, event): self.display_name, [(self.open_configuration, "Properties…")]) def open_configuration(self): - self.app.push_screen(ObjectConfigurationDialog(self.configuration_state)) + self.app.push_screen(ObjectConfigurationDialog(self.configuration_panels)) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 5c0d52a39..752a0efb6 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -80,6 +80,16 @@ def members(cls): def admits(cls, field: DeclaredField) -> bool: return True + @classmethod + def from_field(cls, field: DeclaredField): + """The field provider supplies the declaration used to select its presentation.""" + declaration = field.declaration + parameter = ParameterInfo.create( + name=declaration.name, param_type=field.annotation, + current_value=None, default_value=declaration.default_value, + description=declaration.description, is_required=declaration.is_required) + return cls.showing(type(parameter).__mro__)(field) + @classmethod def of(cls, state: ObjectState, parent: str = "") -> list[FieldView]: """Show ObjectState's direct children; FormBridge supplies each field's kind. diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index e42aba338..67f078240 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -37,9 +37,7 @@ from toad.thread_identity import SessionIdentity from toad.widget_actions import CyclesSessions, TogglesIrc, WidgetAction from toad.widgets.comms_chat import CommsChatView -from toad.widgets.comms_sidebar import CoordinationStatus from toad.widgets.side_bar import SideBar, ThreadSidebar -from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar from toad.worker_group import WorkerGroup @@ -131,21 +129,7 @@ def navigation_target(self) -> NavigationTarget: .sidebar.observation.thread_names(), ) - def make_thread_sidebar(self) -> ThreadSidebar: - return ThreadSidebar( - SideBar.Panel( - "Connection", - CoordinationStatus(), - id="coordination-panel", - ), - SideBar.Panel( - "Comms", - ThreadCommsSidebar(), - id="thread-comms-panel", - header_control=RelationshipSort(), - ), - state=self._thread_sidebar_state, - ) + def compose(self) -> ComposeResult: content = ( diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index a68261a6d..d84fef223 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -216,11 +216,6 @@ class SidebarSessionView[SidebarT: ThreadSidebar](ConfiguredView, ConversationSe def paint_background(self) -> Color: return self.app.color_system.color(BackgroundColour.name) - @cached_property - def _thread_sidebar_state(self) -> SidebarState: - return SidebarState() - @abstractmethod - def make_thread_sidebar(self) -> SidebarT: ... diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index f79533937..c243a3b32 100644 --- a/src/toad/session_tracker.py +++ b/src/toad/session_tracker.py @@ -113,6 +113,7 @@ class SidebarState: selected: SidebarSelection | None = None selected_targets: tuple[SidebarSelection, ...] = () panel_scroll_y: float = 0 + panel_identity: object = None @dataclass(frozen=True) diff --git a/src/toad/settings_scope.py b/src/toad/settings_scope.py index be954c933..8970c8e3e 100644 --- a/src/toad/settings_scope.py +++ b/src/toad/settings_scope.py @@ -33,7 +33,7 @@ class ViewSettingsScope(SettingsScope): @property @abstractmethod - def configuration_panel(self): ... + def configuration_panels(self): ... class ConversationSettingsScope(ViewSettingsScope): diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 075552c7d..6a9909e9f 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -35,7 +35,7 @@ from toad.widget_actions import Action, NativeMethodKeys from toad.widgets.acting_button import ActingButton from toad.widgets.process_text_area import ProcessTextArea -from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver +from toad.widgets.side_bar import SideBar, SidebarVisibilityObserver from toad.widgets.thread_identity import ShowsThreadIdentity from toad.worker_group import WorkerGroup @@ -483,7 +483,7 @@ def presentation_visible(self): self.is_attached and self.query_ancestor(SessionScope).is_current and not self.query_ancestor(SideBar).collapsed - and not self.query_ancestor(SideBarCollapsible).collapsed + and self.panel_visible ) @on(Show) diff --git a/src/toad/widgets/project_panel.py b/src/toad/widgets/project_panel.py index 256386628..a31d9feca 100644 --- a/src/toad/widgets/project_panel.py +++ b/src/toad/widgets/project_panel.py @@ -7,7 +7,7 @@ from textual.reactive import reactive from textual.app import ComposeResult from toad.widget_actions import ActivationKeys -from toad.widgets.side_bar import SidebarPanelWidget +from toad.widgets.side_bar import SidebarPanelHost, SidebarPanelWidget from textual.containers import Vertical, VerticalScroll from textual.message import Message from textual.widgets import Static @@ -117,13 +117,7 @@ def on_show(self) -> None: self.call_after_refresh(self._ensure_tree) def _ensure_tree(self) -> None: - from toad.widgets.side_bar import SideBar, SideBarCollapsible - - if not self.is_attached: - return - sidebar = next((node for node in self.ancestors if isinstance(node, SideBar)), None) - panel = next((node for node in self.ancestors if isinstance(node, SideBarCollapsible)), None) - if sidebar is None or panel is None or sidebar.collapsed or panel.collapsed: + if not self.is_attached or not self.query_ancestor(SidebarPanelHost).panel_visible: return if self.screen.is_current and not self._tree_requested: self._tree_requested = True diff --git a/src/toad/widgets/session_thread_panels.py b/src/toad/widgets/session_thread_panels.py index 70b2a310e..3020b605d 100644 --- a/src/toad/widgets/session_thread_panels.py +++ b/src/toad/widgets/session_thread_panels.py @@ -8,7 +8,7 @@ from toad.widgets.plan import Plan from toad.widgets.project_panel import ProjectPanel, RestorableProjectPanel from toad.widgets.project_tree_intent import NO_TREE_INTENT, TreeIntent -from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarPanelWidget +from toad.widgets.side_bar import SideBar, SidebarPanelWidget from toad.thread_identity import SessionIdentity from toad.widgets.thread_comms import RelationshipSort, RelationshipTreeState, ThreadCommsSidebar @@ -68,13 +68,12 @@ def make_widget(self, screen: MainScreen) -> Plan: def update(self, sidebar: SessionThreadSidebar, entries: list[PlanItem]) -> None: self.entries = entries - title = self.declared_name.title() - if entries: - sidebar.navigation.panels_collapsed[title] = False - if plan := sidebar.query_one_optional(Plan): + if sidebar.panels_loaded: + plan = next(panel.widget for owner, panel in zip(sidebar._panel_owners, sidebar.panels) + if owner is self) plan.entries = entries if entries: - sidebar.query_one(f"#{self.declared_name}-panel", SideBarCollapsible).collapsed = False + sidebar.reveal_panel(plan) class ProjectSessionPanel(SessionPanel[ProjectPanel]): diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 3091b734e..5eb30d47f 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -175,9 +175,29 @@ def controls_changed(self, current: PresentedBar) -> bool: +class SidebarPanelHost(Widget): + """A panel's actual host owns whether its content is presented.""" + + @property + def panel_visible(self): + return self.is_attached and all(node.display for node in self.walk_ancestors(with_self=True) + if isinstance(node, Widget)) + + @abstractmethod + def reveal(self): ... + + class SidebarPanelWidget(Widget): """Every sidebar panel accepts visibility changes; passive panels do nothing.""" + @property + def panel_identity(self): + """The nominal widget role identifies a panel across its lazy realizations.""" + return type(self) + + def finish(self) -> None: + """Passive panels have no pending edit to submit.""" + def sidebar_visibility_changed(self) -> None: pass @@ -185,6 +205,10 @@ def sidebar_visibility_changed(self) -> None: class SidebarVisibilityObserver(SidebarPanelWidget): """A panel whose viewport and active presentation belong to its enclosing sidebar.""" + @property + def panel_visible(self): + return self.query_ancestor(SidebarPanelHost).panel_visible + @property def scroll_container(self) -> SidebarViewport: return self.query_ancestor(SideBar).panels_viewport @@ -199,6 +223,7 @@ class SidebarPanelContents(AutoHeight, NoPadding, widgets.Collapsible.Contents): class SideBarCollapsible( + SidebarPanelHost, FillWidth, AutoHeight, NoPadding, @@ -224,6 +249,13 @@ class SideBarCollapsible( expanded_min_height = 1 """Rows an open panel keeps; a collapsed panel is its one title row.""" + @property + def panel_visible(self): + return super().panel_visible and not self.collapsed + + def reveal(self): + self.collapsed = False + def __init__( self, *children: Widget, header_control: Widget | None = None, **kwargs ): @@ -1226,6 +1258,9 @@ def gutter_hover_changed(self) -> None: if self.presentation_visible: self.toggle_control.refresh_paint() + def focus_contents(self): + self.panels_viewport.focus() + def toggle(self, *, focus: bool = True) -> None: collapsed = not self.collapsed return_focus = collapsed and (focus or self.has_focus_within) @@ -1240,8 +1275,8 @@ def toggle(self, *, focus: bool = True) -> None: # Panels are already displayed by the synchronous watcher. Queue # native focus now so its highlight can share the opening frame, # rather than waiting for a painted frame to request another one. - if title := self.query_one_optional("SideBarCollapsible CollapsibleTitle"): - title.focus() + if self.panels_loaded: + self.focus_contents() else: self.schedule_hydration() @@ -1254,12 +1289,87 @@ def dismiss_control(self) -> None: self.post_message(self.Dismiss()) +class PanelBody(FillHeight, containers.Vertical): + """The space left below the selected-view menu.""" + + +class PanelPage(SidebarPanelHost, PanelBody): + """The original panel and its controls, retained while another entry is selected.""" + + def reveal(self): + menu = self.query_ancestor(PanelMenu) + menu.picker.value = next(entry for entry, page in menu.pages.items() if page is self) + + +class PanelPicker(OneRow, FillWidth, widgets.Select): + """One compact selector over the entries supplied by the viewed object.""" + + +class PanelMenu(FillHeight, containers.Vertical): + """Discover once from the owner; mount each original panel only when selected.""" + + def __init__(self, entries, navigation: SidebarState): + super().__init__() + self.entries = tuple(entries) + self.navigation = navigation + selected = next((entry for entry in self.entries + if entry.widget.panel_identity == navigation.panel_identity), self.entries[0]) + self.picker = PanelPicker(((entry.title, entry) for entry in self.entries), + value=selected, allow_blank=False, compact=True) + self.body = PanelBody() + self.pages: dict[SideBar.Panel, PanelPage] = {} + + def compose(self): + yield self.picker + yield self.body + + @on(events.Mount) + async def open_selection(self): + await self.show_panel(self.picker.value) + + @on(widgets.Select.Changed) + async def selection_changed(self, event): + if event.select is self.picker and event.value is not widgets.Select.NULL: + event.stop() + await self.show_panel(event.value) + + async def show_panel(self, entry): + for panel, page in self.pages.items(): + if page.display and panel is not entry: + panel.widget.finish() + page.display = panel is entry + panel.widget.sidebar_visibility_changed() + if entry not in self.pages: + children = (() if entry.header_control is None else (entry.header_control,)) + page = PanelPage(*children, entry.widget, id=entry.id) + self.pages[entry] = page + await self.body.mount(page) + self.navigation.panel_identity = entry.widget.panel_identity + entry.widget.sidebar_visibility_changed() + + def activate(self, widget): + self.picker.value = next(entry for entry in self.entries if entry.widget is widget) + + def finish(self): + for entry in self.pages: + entry.widget.finish() + + class ThreadSidebar(SideBar): placement_type = ThreadSidebarPlacement def panel_entries(self): yield from super().panel_entries() - yield self.query_ancestor(ViewSettingsScope).configuration_panel + yield from self.query_ancestor(ViewSettingsScope).configuration_panels + + def _compose_panels(self): + yield PanelMenu(self.panel_entries(), self.navigation) + + def focus_contents(self): + self.query_one(PanelPicker).focus() + + def reveal_panel(self, widget): + self.query_one(PanelMenu).activate(widget) def __init__(self, *panels, state: SidebarState, **kwargs): self.state = state diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 98b024e0f..6e6e974d4 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -47,7 +47,11 @@ from toad.widgets.comms_menu import ActionMenu from toad.widgets.comms_sidebar import ThreadStatusRow from toad.widgets.session_sort import SortControl, ThreadSortForm -from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver +from toad.widgets.side_bar import ( + SideBar, + SidebarPanelHost, + SidebarVisibilityObserver, +) from toad.widgets.sidebar_tree import ( Disclosure, GroupTitle, @@ -88,7 +92,7 @@ def update_groups(self, groups): self.update_order(next(iter(orders)) if len(orders) == 1 else None) async def order_writer(self): - tree = self.query_ancestor(SideBarCollapsible).query_one(ThreadCommsSidebar) + tree = self.query_ancestor(SidebarPanelHost).query_one(ThreadCommsSidebar) identity, generation = tree.thread_view.thread_identity, tree._generation snapshot = tree._snapshot if snapshot is None: @@ -371,7 +375,7 @@ def _reset_relationships(self) -> None: def accepts_observation(self) -> bool: identity = self.thread_view.thread_identity - return self.model_state.serves(identity.root_path) + return self.panel_visible and self.model_state.serves(identity.root_path) def sync(self, *, change: SidebarChange, reorder: bool = False) -> None: change.apply_relationships(self.navigation_groups) @@ -381,6 +385,7 @@ def _presentation_visible(self) -> bool: self.is_attached and self.screen.is_current and self.query_ancestor(SideBar).presentation_visible + and self.panel_visible ) @handles(core_events.OpenTabsChanged, core_events.SessionSelected) @@ -427,7 +432,7 @@ def _visible(self): self.is_attached and self.screen.is_current and self in self.screen._compositor.visible_widgets - and not self.query_ancestor(SideBarCollapsible).collapsed + and self.panel_visible ) def refresh_relationships(self, *, force=False): @@ -485,7 +490,7 @@ async def _refresh_owner(self, identity: ThreadIdentity, generation): snapshot.incoming_basis + f"\nLatest {snapshot.history_messages} wire messages" ) - control = self.query_ancestor(SideBarCollapsible).query_one_optional( + control = self.query_ancestor(SidebarPanelHost).query_one_optional( RelationshipSort ) if control is not None: diff --git a/tests/textual/test_sidebar_projection.py b/tests/textual/test_sidebar_projection.py index 6748aeec5..a195a6023 100644 --- a/tests/textual/test_sidebar_projection.py +++ b/tests/textual/test_sidebar_projection.py @@ -9,6 +9,80 @@ from toad.widgets.side_bar import SideBar +async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspace): + from toad.configured_view import ObjectProperties + from toad.forms.object_configuration import ObjectValueView + from toad.preferences import SETTINGS + from toad.widgets.side_bar import PanelMenu + from toad.widgets.thread_comms import ThreadCommsSidebar + + workspace.comms.channels.create_tag('settings') + preview = workspace.project / 'example.txt' + preview.write_text('A viewed file\n') + async with workspace.app(size=(140, 45)) as ui: + app, pilot = ui.textual + original = ui.selected_tab + channel = await ui.open_channel('#settings', actor=workspace.viewer) + view = app.workspace_sessions.require(channel) + bar = await ui.revealed(view.thread_sidebar) + menu = bar.query_one(PanelMenu) + assert not bar.query(ThreadCommsSidebar) + assert menu.entries and all(isinstance(entry.widget, ObjectProperties) for entry in menu.entries) + assert len(menu.pages) == 1 + state = view.configuration_state + assert state.identity == app.session_navigation.get(channel).identity + await ui.until(lambda: bool(menu.query(ObjectValueView)), why='selected settings fields') + assert 'ui.column_width' in {part.field.path.value for part in menu.query(ObjectValueView)} + assert menu.region.width and menu.query_one(ObjectValueView).region.height + ui_entry = menu.picker.value + other = next(entry for entry in menu.entries if entry is not ui_entry) + menu.picker.value = other + await ui.until(lambda: menu.navigation.panel_identity == other.widget.panel_identity, why='dropdown selection') + menu.picker.value = ui_entry + await ui.until(lambda: menu.navigation.panel_identity == ui_entry.widget.panel_identity, why='returning to settings') + app.saved_settings.set(SETTINGS.ui.column_width, 83) + assert state.fields.ui.column_width == 83 + state.field('ui.column_width').edit(91) + app.saved_settings.set(SETTINGS.ui.column_width, 85) + assert state.fields.ui.column_width == 91 + state.reset_parameter('ui.column_width') + assert state.fields.ui.column_width == 85 + file_mode = await ui.open_preview(preview) + file_view = app.workspace_sessions.require(file_mode) + file_bar = await ui.revealed(file_view.thread_sidebar) + file_menu = file_bar.query_one(PanelMenu) + assert file_view.configuration_state.identity == app.session_navigation.get(file_mode).identity + assert file_view.configuration_state is not state + assert not file_bar.query(ThreadCommsSidebar) + assert len(file_menu.entries) < len(menu.entries) + await ui.select_tab(channel) + assert view.thread_sidebar is bar and bar.query_one(PanelMenu) is menu + assert menu.picker.value is ui_entry + view.open_configuration() + await ui.step() + dialog = app.screen + assert all(entry.widget.field.state is state for entry in dialog.form.entries) + await ui.until(lambda: bool(dialog.query(ObjectValueView)), why='the shared context editor') + dialog.dismiss_control() + await ui.step() + await ui.select_tab(original) + native = app.workspace_sessions.require(original) + workspace.thread('panel-owner') + await workspace.bind(native, 'panel-owner') + native_bar = await ui.revealed(native.thread_sidebar) + native_menu = native_bar.query_one(PanelMenu) + relationships = next(entry for entry in native_menu.entries + if isinstance(entry.widget, ThreadCommsSidebar)) + native_menu.picker.value = relationships + await ui.until(lambda: relationships.widget.is_mounted, why='the native relationship panel') + assert relationships.widget.panel_visible + relationships.widget._visible() + settings_entry = next(entry for entry in native_menu.entries + if isinstance(entry.widget, ObjectProperties)) + native_menu.picker.value = settings_entry + await ui.until(lambda: not relationships.widget.panel_visible, why='the hidden native panel') + + class FrameApp(ToadApp): """Records the channels sidebar's collapsed state, width and panel visibility of every frame drawn while `frames` is a list."""