From 8c8be55224f0d6098d9d0f1bc20aa03430cebbd4 Mon Sep 17 00:00:00 2001 From: Patrick O'Leary Date: Tue, 5 May 2026 12:33:35 -0500 Subject: [PATCH] refactor: slice selection toolbar with toggle groups - Each variable is now a UI group with a toggle button showing the track name - When toggled, reveals editable textfield and slider controls - Groups wrap to new rows with flex-wrap when space is limited - Value label shows bold (t_idx) when collapsed, italic value+units always - Simplified top_padding calculation (no multi-row pre-computation needed) - Use default density for components, darker group outline, larger slider --- src/e3sm_quickview/app.py | 31 +--- src/e3sm_quickview/components/css.py | 2 +- src/e3sm_quickview/components/toolbars.py | 213 ++++++++++------------ src/e3sm_quickview/view_manager.py | 2 +- 4 files changed, 102 insertions(+), 146 deletions(-) diff --git a/src/e3sm_quickview/app.py b/src/e3sm_quickview/app.py index ff12d06..524204c 100644 --- a/src/e3sm_quickview/app.py +++ b/src/e3sm_quickview/app.py @@ -1,7 +1,6 @@ import asyncio import datetime import json -import math import os import time from functools import partial @@ -221,11 +220,12 @@ def _build_ui(self, **_): with v3.VContainer(classes="h-100 pa-0", fluid=True): with client.SizeObserver("main_size"): - # Take space to push content below the fixed overlay - html.Div(style=("`height: ${top_padding}px`",)) - - # Fixed overlay for toolbars + # Sticky toolbar overlay with html.Div(style=css.TOOLBARS_FIXED_OVERLAY): + client.SizeObserver( + "toolbar_size", + style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;", + ) toolbars.Layout( apply_size=self.view_manager.apply_size, zoom=self.view_manager.zoom, @@ -611,27 +611,6 @@ async def _on_projection(self, projection, **_): await asyncio.sleep(0.1) self.view_manager.reset_camera() - @change("active_tools", "available_animation_tracks") - def _on_toolbar_change(self, active_tools, **_): - top_padding = 0 - for name in active_tools: - if name == "select-slice-time": - track_count = len(self.state.available_animation_tracks or []) - rows_needed = 1 - if track_count > 3: - if track_count % 3 == 0 or (track_count + 1) % 3 == 0: - rows_needed = math.ceil(track_count / 3) - elif track_count % 2 == 0: - rows_needed = track_count / 2 - else: - rows_needed = math.ceil(track_count / 3) - - top_padding += 70 * rows_needed - else: - top_padding += toolbars.SIZES.get(name, 0) - - self.state.top_padding = top_padding - def _on_slicing_change(self, var, ind_var, **_): with perf.timed(f"tick.{var}={self.state[ind_var]}.total"): with perf.timed("tick.pipeline"): diff --git a/src/e3sm_quickview/components/css.py b/src/e3sm_quickview/components/css.py index ea01085..0c80d8c 100644 --- a/src/e3sm_quickview/components/css.py +++ b/src/e3sm_quickview/components/css.py @@ -4,7 +4,7 @@ ) TOOLBARS_FIXED_OVERLAY = ( - "`position:fixed;top:0;width:${Math.floor(main_size?.size?.width || 0)}px;z-index:1;`", + "`position:sticky;top:0;width:${Math.floor(main_size?.size?.width || 0)}px;z-index:1;background:rgb(var(--v-theme-surface));`", ) diff --git a/src/e3sm_quickview/components/toolbars.py b/src/e3sm_quickview/components/toolbars.py index 9970760..b7e196a 100644 --- a/src/e3sm_quickview/components/toolbars.py +++ b/src/e3sm_quickview/components/toolbars.py @@ -7,35 +7,6 @@ from e3sm_quickview.utils import js -DENSITY = { - "adjust-layout": "compact", - "adjust-databounds": "default", - "select-slice-time": "default", - "animation-controls": "compact", -} - -SIZES = { - "adjust-layout": 49, - "adjust-databounds": 65, - "select-slice-time": 70, - "animation-controls": 49, -} - -VALUES = list(DENSITY.keys()) - -DEFAULT_STYLES = { - "color": "white", - "classes": "border-b-thin", -} - - -def to_kwargs(value): - return { - "v_show": js.is_active(value), - "density": DENSITY[value], - **DEFAULT_STYLES, - } - class Layout(v3.VToolbar): def __init__( @@ -45,7 +16,12 @@ def __init__( pan=None, reset_camera=None, ): - super().__init__(**to_kwargs("adjust-layout")) + super().__init__( + v_show=js.is_active("adjust-layout"), + density="compact", + color="white", + classes="border-b-thin", + ) self.state.setdefault("show_zoom_controls", False) self.state.setdefault("show_pan_controls", False) @@ -311,7 +287,12 @@ def __init__( class Cropping(v3.VToolbar): def __init__(self): - super().__init__(**to_kwargs("adjust-databounds")) + super().__init__( + v_show=js.is_active("adjust-databounds"), + density="default", + color="white", + classes="border-b-thin", + ) with self: with v3.VTooltip( @@ -439,110 +420,101 @@ def _on_crop_lat(self, crop_latitude_min, crop_latitude_max, **_): class DataSelection(html.Div): def __init__(self): - style = to_kwargs("select-slice-time") - # Use style instead of d-flex class to avoid !important override of v-show - # Add background color to match VToolbar appearance - style["style"] = ( - "display: flex; align-items: center; background: rgb(var(--v-theme-surface));" + super().__init__( + v_show=js.is_active("select-slice-time"), + classes="border-b-thin", + style="display: flex; align-items: center; background: rgb(var(--v-theme-surface));", ) - super().__init__(**style) + + self.state.setdefault("expanded_slice_track", None) with self: - with v3.VTooltip( - text=( - "slice_slider_edit ? 'Toggle to text edit' : 'Toggle to slider edit'", - ), - ): - with v3.Template(v_slot_activator="{ props }"): - v3.VIcon( - "mdi-tune-variant", - v_bind="props", - classes="ml-3 mr-2 opacity-50", - click="slice_slider_edit = !slice_slider_edit", - ) + v3.VIcon("mdi-tune-variant", classes="ml-3 mr-2 opacity-50") - with v3.VRow( - classes="ma-0 pr-2 flex-wrap flex-grow-1", - dense=True, - v_if=("slice_slider_edit", True), + with html.Div( + classes="d-flex align-center flex-wrap flex-grow-1 ga-1 py-1 pr-2" ): - # Debug: Show animation_tracks array - # html.Div( - # "Animation Tracks: {{ JSON.stringify(available_animation_tracks) }}", - # classes="col-12", - # ) - # Each track gets a column (3 per row) - with v3.VCol( - cols=("utils.quickview.cols(available_animation_tracks.length)",), + with html.Template( v_for="(track, idx) in available_animation_tracks", key="idx", - classes="pa-2", ): with client.Getter(name=("track",), value_name="t_values"): with client.Getter( name=("track + '_idx'",), value_name="t_idx" ): - with v3.VRow(classes="ma-0 align-center", dense=True): - v3.VLabel( - "{{track}}", - classes="text-subtitle-2", - ) - v3.VSpacer() - v3.VLabel( - "{{ dim_units[track] ? parseFloat(t_values[t_idx]).toFixed(2) + ' ' + dim_units[track] : 'Index value: ' + t_idx }} (k={{ t_idx }})", - classes="text-body-2", - ) - v3.VSlider( - model_value=("t_idx",), - update_modelValue=( - self.on_update_slider, - "[track, $event]", + # --- Per-variable group --- + with v3.VSheet( + classes="d-flex align-center rounded px-1 ga-1", + color=( + "expanded_slice_track === track ? 'grey-lighten-3' : 'grey-lighten-4'", ), - min=0, - # max=100,#("get(track.value).length - 1",), - max=("t_values.length - 1",), - step=1, - density="compact", - hide_details=True, - ) - with v3.VRow( - classes="ma-0 pl-6 pr-2 align-center ga-4", - v_if="!slice_slider_edit", - ): - with v3.VCol( - v_for="(track, idx) in available_animation_tracks", - key="idx", - ): - with client.Getter(name=("track",), value_name="t_values"): - with client.Getter( - name=("track + '_idx'",), value_name="t_idx" - ): - with v3.VRow(classes="ma-0 align-center", dense=True): - v3.VNumberInput( - model_value=("Number(t_idx)",), - update_modelValue=( - self.on_update_slider, - "[track, Number($event)]", - ), - key=("track + '_' + t_idx",), - min=[0], - max=["t_values ? t_values.length - 1 : 0"], - step=[1], - hide_details=True, - density="comfortable", - variant="plain", + ): + # Toggle button with track name + v3.VBtn( + "{{ track }}", + v_tooltip_bottom="'Toggle ' + track + ' controls'", flat=True, - control_variant="stacked", - style="max-width: 100px;", - reverse=True, + variant=( + "expanded_slice_track === track ? 'flat' : 'outlined'", + ), + rounded=True, + click="expanded_slice_track = expanded_slice_track === track ? null : track", + color=( + "expanded_slice_track === track ? 'primary' : ''", + ), + style=( + "'text-transform: none;' + (expanded_slice_track === track ? '' : ' background-color: white;')", + ), ) + # Expanded controls + with html.Div( + v_if="expanded_slice_track === track", + classes="d-flex align-center ga-1", + style="height: 36px; overflow: visible;", + ): + v3.VDivider(vertical=True, classes="mx-1") + # Text input + html.Input( + type="number", + value=("t_idx",), + min=[0], + max=["t_values ? t_values.length - 1 : 0"], + step=[1], + change=( + self.on_update_slider, + "[track, Number($event.target.value)]", + ), + style="width: 60px; height: 28px; padding: 16px 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; box-sizing: border-box; text-align: right;", + ) + # Slider + v3.VSlider( + v_tooltip_bottom=( + "dim_units[track] ? parseFloat(t_values[t_idx]).toFixed(2) + ' ' + dim_units[track] : 'Index: ' + t_idx", + ), + model_value=("t_idx",), + update_modelValue=( + self.on_update_slider, + "[track, $event]", + ), + min=0, + max=("t_values.length - 1",), + step=1, + show_ticks="always", + hide_details=True, + density="compact", + style="min-width: 200px; max-width: 400px;", + ) + # Index label (shown when collapsed) v3.VLabel( - "{{track}}", - classes="text-subtitle-2 ml-2 mt-1", + v_if="expanded_slice_track !== track", + v_text="`(${t_idx})`", + classes="font-weight-bold", ) + # Value + units label v3.VLabel( - "{{ dim_units[track] ? parseFloat(t_values[Number(t_idx)]).toFixed(2) + ' ' + dim_units[track] : 'Index value: ' + t_idx }}", - classes="text-body-2 text-no-wrap ml-2 mt-1", + v_if="dim_units[track] && isNaN(Number(dim_units[track]))", + v_text="`${parseFloat(t_values[t_idx]).toFixed(2)} ${dim_units[track]}`", + classes="font-italic text-medium-emphasis", ) def on_update_slider(self, dimension, index, *_, **__): @@ -552,7 +524,12 @@ def on_update_slider(self, dimension, index, *_, **__): class Animation(v3.VToolbar): def __init__(self): - super().__init__(**to_kwargs("animation-controls")) + super().__init__( + v_show=js.is_active("animation-controls"), + density="compact", + color="white", + classes="border-b-thin", + ) with self: v3.VIcon( "mdi-video", diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index f09f3b9..c5c5956 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -894,7 +894,7 @@ def _build_ui(self): with v3.VCard( variant="tonal", style=( - "active_layout !== 'auto_layout' ? `height: calc(100% - ${top_padding}px;` : 'overflow-hidden'", + "active_layout !== 'auto_layout' ? `height: calc(100% - ${toolbar_size?.size?.height || 0}px)` : 'overflow-hidden'", ), tile=("active_layout !== 'auto_layout'",), raw_attrs=[f'data-field-name="{self.variable_name}"'],