From 8b74a5c748492303b54a4586cfcf5df8496e8b3c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 2 Sep 2025 18:00:13 +0200 Subject: [PATCH 01/32] feat: add accordions to tabs to sort controls --- python/examples/example_accordion.py | 36 +++ python/neuroglancer/viewer_state.py | 82 +++++++ src/datasource/graphene/frontend.ts | 2 +- src/layer/annotation/index.ts | 17 +- src/layer/image/index.ts | 67 +++++- src/layer/index.ts | 30 +++ src/layer/segmentation/index.ts | 46 +++- src/layer/segmentation/layer_controls.ts | 21 +- src/ui/annotations.ts | 50 +++- src/ui/layer_data_sources_tab.css | 1 - src/ui/layer_data_sources_tab.ts | 29 ++- src/ui/segmentation_display_options_tab.ts | 36 ++- src/widget/accordion.css | 52 ++++ src/widget/accordion.ts | 261 +++++++++++++++++++++ src/widget/layer_control.ts | 1 + 15 files changed, 686 insertions(+), 45 deletions(-) create mode 100644 python/examples/example_accordion.py create mode 100644 src/widget/accordion.css create mode 100644 src/widget/accordion.ts diff --git a/python/examples/example_accordion.py b/python/examples/example_accordion.py new file mode 100644 index 0000000000..e947f37bc1 --- /dev/null +++ b/python/examples/example_accordion.py @@ -0,0 +1,36 @@ +import argparse + +import neuroglancer +import neuroglancer.cli +import numpy as np + + +def add_example_layers(state): + state.dimensions = neuroglancer.CoordinateSpace( + names=["x", "y", "z"], units="nm", scales=[10, 10, 10] + ) + state.layers.append( + name="example_layer", + layer=neuroglancer.LocalVolume( + data=np.random.rand(10, 10, 10).astype(np.float32), + dimensions=state.dimensions, + ), + ) + return state.layers[0] + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + neuroglancer.cli.add_server_arguments(ap) + args = ap.parse_args() + neuroglancer.cli.handle_server_arguments(args) + viewer = neuroglancer.Viewer() + with viewer.txn() as s: + add_example_layers(s) + s.layers[0].annotations_accordion.annotations_expanded = False + s.layers[0].annotations_accordion.related_segments_expanded = True + s.layers[0].rendering_accordion.slice_expanded = True + s.layers[0].rendering_accordion.shader_expanded = False + s.layers[0].source_accordion.source_expanded = False + + print(viewer) diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index bd8502ac9c..14d155bfeb 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -406,6 +406,75 @@ class DimensionPlaybackVelocity(JsonObjectWrapper): paused = wrapped_property("paused", optional(bool, True)) +@export +class SourceAccordion(JsonObjectWrapper): + """Accordion state for layer data source controls.""" + + __slots__ = () + + source_expanded = sourceExpanded = wrapped_property( + "sourceExpanded", optional(bool) + ) + create_expanded = createExpanded = wrapped_property( + "createExpanded", optional(bool) + ) + + +@export +class AnnotationsAccordion(JsonObjectWrapper): + """Accordion state for layer annotation controls.""" + + __slots__ = () + + spacing_expanded = spacingExpanded = wrapped_property( + "spacingExpanded", optional(bool) + ) + related_segments_expanded = relatedSegmentsExpanded = wrapped_property( + "relatedSegmentsExpanded", optional(bool) + ) + annotations_expanded = annotationsExpanded = wrapped_property( + "annotationsExpanded", optional(bool) + ) + + +@export +class ImageRenderingAccordion(JsonObjectWrapper): + """Accordion state for image layer rendering controls.""" + + __slots__ = () + + slice_expanded = sliceExpanded = wrapped_property("sliceExpanded", optional(bool)) + volume_rendering_expanded = volumeRenderingExpanded = wrapped_property( + "volumeRenderingExpanded", optional(bool) + ) + shader_expanded = shaderExpanded = wrapped_property( + "shaderExpanded", optional(bool) + ) + + +@export +class SegmentationRenderingAccordion(JsonObjectWrapper): + """Accordion state for segmentation layer rendering controls.""" + + __slots__ = () + + visibility_expanded = visibilityExpanded = wrapped_property( + "visibilityExpanded", optional(bool) + ) + appearance_expanded = appearanceExpanded = wrapped_property( + "appearanceExpanded", optional(bool) + ) + slice_rendering_expanded = sliceRenderingExpanded = wrapped_property( + "sliceRenderingExpanded", optional(bool) + ) + mesh_rendering_expanded = meshRenderingExpanded = wrapped_property( + "meshRenderingExpanded", optional(bool) + ) + skeletons_expanded = skeletonsExpanded = wrapped_property( + "skeletonsExpanded", optional(bool) + ) + + @export class Layer(JsonObjectWrapper): __slots__ = () @@ -428,6 +497,13 @@ class Layer(JsonObjectWrapper): ) tool = wrapped_property("tool", optional(Tool)) + annotations_accordion = annotationsAccordion = wrapped_property( + "annotationsAccordion", AnnotationsAccordion + ) + source_accordion = sourceAccordion = wrapped_property( + "sourceAccordion", SourceAccordion + ) + @staticmethod def interpolate(a, b, t): c = copy.deepcopy(a) @@ -609,6 +685,9 @@ def __init__(self, *args, **kwargs): cross_section_render_scale = crossSectionRenderScale = wrapped_property( "crossSectionRenderScale", optional(float, 1) ) + rendering_accordion = renderingAccordion = wrapped_property( + "renderingAccordion", ImageRenderingAccordion + ) @staticmethod def interpolate(a, b, t): @@ -946,6 +1025,9 @@ def visible_segments(self, segments): skeleton_rendering = skeletonRendering = wrapped_property( "skeletonRendering", SkeletonRenderingOptions ) + rendering_accordion = renderingAccordion = wrapped_property( + "renderingAccordion", SegmentationRenderingAccordion + ) @property def skeleton_shader(self): diff --git a/src/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index d3fc71fa18..73d73cb912 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -1954,7 +1954,7 @@ class MulticutAnnotationLayerView extends AnnotationLayerView { public layer: SegmentationUserLayer, public displayState: AnnotationDisplayState, ) { - super(layer, displayState); + super(layer, displayState, layer.annotationAccordionState); const { graphConnection: { value: graphConnection }, } = layer; diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 3195eedac1..2e13b8fdb6 100644 --- a/src/layer/annotation/index.ts +++ b/src/layer/annotation/index.ts @@ -56,7 +56,11 @@ import type { AnnotationLayerView, MergedAnnotationStates, } from "#src/ui/annotations.js"; -import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; +import { + RELATED_SEGMENT_SECTION_JSON_KEY, + SPACING_SECTION_JSON_KEY, + UserLayerWithAnnotationsMixin, +} from "#src/ui/annotations.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -675,12 +679,14 @@ export class AnnotationUserLayer extends Base { renderScaleWidget.label.textContent = "Spacing (projection)"; parent.appendChild(renderScaleWidget.element); } + tab.showSection(SPACING_SECTION_JSON_KEY); }, ), ); - tab.element.insertBefore( + tab.appendChild( renderScaleControls.element, - tab.element.firstChild, + SPACING_SECTION_JSON_KEY, + true /* hidden */, ); { const checkbox = tab.registerDisposer( @@ -695,12 +701,13 @@ export class AnnotationUserLayer extends Base { label.title = "Display all annotations if filtering by related segments is enabled but no segments are selected"; label.appendChild(checkbox.element); - tab.element.appendChild(label); + tab.appendChild(label, RELATED_SEGMENT_SECTION_JSON_KEY); } - tab.element.appendChild( + tab.appendChild( tab.registerDisposer( new LinkedSegmentationLayersWidget(this.linkedSegmentationLayers), ).element, + RELATED_SEGMENT_SECTION_JSON_KEY, ); } diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index 241b69853e..0ce6c95259 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -79,6 +79,7 @@ import { setControlsInShader, ShaderControlState, } from "#src/webgl/shader_ui_controls.js"; +import { AccordionState, AccordionTab } from "#src/widget/accordion.js"; import { ChannelDimensionsWidget } from "#src/widget/channel_dimensions_widget.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; @@ -102,7 +103,6 @@ import { registerLayerShaderControlsTool, ShaderControls, } from "#src/widget/shader_controls.js"; -import { Tab } from "#src/widget/tab_view.js"; const OPACITY_JSON_KEY = "opacity"; const BLEND_JSON_KEY = "blend"; @@ -114,6 +114,10 @@ const CHANNEL_DIMENSIONS_JSON_KEY = "channelDimensions"; const VOLUME_RENDERING_JSON_KEY = "volumeRendering"; const VOLUME_RENDERING_GAIN_JSON_KEY = "volumeRenderingGain"; const VOLUME_RENDERING_DEPTH_SAMPLES_JSON_KEY = "volumeRenderingDepthSamples"; +const RENDERING_ACCORDION_JSON_KEY = "renderingAccordion"; +const SLICE_SECTION_JSON_KEY = "sliceExpanded"; +const VOLUME_RENDERING_SECTION_JSON_KEY = "volumeRenderingExpanded"; +const SHADER_SECTION_JSON_KEY = "shaderExpanded"; export interface ImageLayerSelectionState extends UserLayerSelectionState { value: any; @@ -157,6 +161,28 @@ export class ImageUserLayer extends Base { ); volumeRenderingMode = trackableShaderModeValue(); + renderingAccordionState = this.registerDisposer( + new AccordionState({ + accordionJsonKey: RENDERING_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: SLICE_SECTION_JSON_KEY, + displayName: "Slice 2D", + }, + { + jsonKey: VOLUME_RENDERING_SECTION_JSON_KEY, + displayName: "Volume rendering", + }, + { + jsonKey: SHADER_SECTION_JSON_KEY, + displayName: "Shader controls", + defaultExpanded: true, + isDefaultKey: true, + }, + ], + }), + ); + shaderControlState = this.registerDisposer( new ShaderControlState( this.fragmentMain, @@ -219,10 +245,13 @@ export class ImageUserLayer extends Base { this.volumeRenderingDepthSamplesTarget.changed.add( this.specificationChanged.dispatch, ); + this.renderingAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.tabs.add("rendering", { label: "Rendering", order: -100, - getter: () => new RenderingOptionsTab(this), + getter: () => new RenderingOptionsTab(this, this.renderingAccordionState), }); this.tabs.default = "rendering"; } @@ -339,6 +368,13 @@ export class ImageUserLayer extends Base { volumeRenderingDepthSamplesTarget, ), ); + verifyOptionalObjectProperty( + specification, + RENDERING_ACCORDION_JSON_KEY, + (accordionState) => { + this.renderingAccordionState.restoreState(accordionState); + }, + ); } toJSON() { const x = super.toJSON(); @@ -354,6 +390,7 @@ export class ImageUserLayer extends Base { x[VOLUME_RENDERING_GAIN_JSON_KEY] = this.volumeRenderingGain.toJSON(); x[VOLUME_RENDERING_DEPTH_SAMPLES_JSON_KEY] = this.volumeRenderingDepthSamplesTarget.toJSON(); + x[RENDERING_ACCORDION_JSON_KEY] = this.renderingAccordionState.toJSON(); return x; } @@ -470,6 +507,7 @@ const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Resolution (slice)", toolJson: CROSS_SECTION_RENDER_SCALE_JSON_KEY, + sectionKey: SLICE_SECTION_JSON_KEY, ...renderScaleLayerControl((layer) => ({ histogram: layer.sliceViewRenderScaleHistogram, target: layer.sliceViewRenderScaleTarget, @@ -478,21 +516,25 @@ const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Blending (slice)", toolJson: BLEND_JSON_KEY, + sectionKey: SLICE_SECTION_JSON_KEY, ...enumLayerControl((layer) => layer.blendMode), }, { label: "Opacity (slice)", toolJson: OPACITY_JSON_KEY, + sectionKey: SLICE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.opacity })), }, { label: "Volume rendering (experimental)", toolJson: VOLUME_RENDERING_JSON_KEY, + sectionKey: VOLUME_RENDERING_SECTION_JSON_KEY, ...enumLayerControl((layer) => layer.volumeRenderingMode), }, { label: "Gain (3D)", toolJson: VOLUME_RENDERING_GAIN_JSON_KEY, + sectionKey: VOLUME_RENDERING_SECTION_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( (volumeRenderingMode) => @@ -507,6 +549,7 @@ const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Resolution (3D)", toolJson: VOLUME_RENDERING_DEPTH_SAMPLES_JSON_KEY, + sectionKey: VOLUME_RENDERING_SECTION_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( (volumeRenderingMode) => @@ -527,21 +570,25 @@ for (const control of LAYER_CONTROLS) { registerLayerControl(ImageUserLayer, control); } -class RenderingOptionsTab extends Tab { +class RenderingOptionsTab extends AccordionTab { codeWidget: ShaderCodeWidget; - constructor(public layer: ImageUserLayer) { - super(); + constructor( + public layer: ImageUserLayer, + protected accordionState: AccordionState, + ) { + super(accordionState); const { element } = this; this.codeWidget = this.registerDisposer(makeShaderCodeWidget(this.layer)); element.classList.add("neuroglancer-image-dropdown"); for (const control of LAYER_CONTROLS) { - element.appendChild( + this.appendChild( addLayerControlToOptionsTab(this, layer, this.visibility, control), + control.sectionKey, ); } - element.appendChild( + this.appendChild( makeShaderCodeWidgetTopRow( this.layer, this.codeWidget, @@ -553,14 +600,14 @@ class RenderingOptionsTab extends Tab { "neuroglancer-image-dropdown-top-row", ), ); - element.appendChild( + this.appendChild( this.registerDisposer( new ChannelDimensionsWidget(layer.channelCoordinateSpaceCombiner), ).element, ); - element.appendChild(this.codeWidget.element); - element.appendChild( + this.appendChild(this.codeWidget.element); + this.appendChild( this.registerDisposer( new ShaderControls( layer.shaderControlState, diff --git a/src/layer/index.ts b/src/layer/index.ts index 98be208af6..2c779a49b7 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -109,6 +109,7 @@ import { import type { Trackable } from "#src/util/trackable.js"; import { kEmptyFloat32Vec } from "#src/util/vector.js"; import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; +import { AccordionState } from "#src/widget/accordion.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import type { Tab } from "#src/widget/tab_view.js"; import { TabSpecification } from "#src/widget/tab_view.js"; @@ -122,6 +123,9 @@ const LOCAL_COORDINATE_SPACE_JSON_KEY = "localDimensions"; const SOURCE_JSON_KEY = "source"; const TRANSFORM_JSON_KEY = "transform"; const PICK_JSON_KEY = "pick"; +const SOURCE_ACCORDION_JSON_KEY = "sourceAccordion"; +const DATA_SECTION_JSON_KEY = "sourceExpanded"; +export const CREATE_SECTION_JSON_KEY = "createExpanded"; export interface UserLayerSelectionState { generation: number; @@ -360,6 +364,24 @@ export class UserLayer extends RefCounted { } tabs = this.registerDisposer(new TabSpecification()); + sourceAccordionState = this.registerDisposer( + new AccordionState({ + accordionJsonKey: SOURCE_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: DATA_SECTION_JSON_KEY, + displayName: "Data sources", + defaultExpanded: true, + isDefaultKey: true, + }, + { + jsonKey: CREATE_SECTION_JSON_KEY, + displayName: "Initial settings", + defaultExpanded: true, + }, + ], + }), + ); panels = new UserLayerSidePanelsState(this); tool = this.registerDisposer(new SelectedLegacyTool(this)); toolBinder: LayerToolBinder; @@ -379,6 +401,9 @@ export class UserLayer extends RefCounted { this.localCoordinateSpaceCombiner.includeDimensionPredicate = isLocalOrChannelDimension; this.tabs.changed.add(this.specificationChanged.dispatch); + this.sourceAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.panels.specificationChanged.add(this.specificationChanged.dispatch); this.tool.changed.add(this.specificationChanged.dispatch); this.toolBinder.changed.add(this.specificationChanged.dispatch); @@ -388,6 +413,7 @@ export class UserLayer extends RefCounted { this.dataSourcesChanged.add(this.specificationChanged.dispatch); this.dataSourcesChanged.add(() => this.updateDataSubsourceActivations()); this.messages.changed.add(this.layersChanged.dispatch); + for (const tab of USER_LAYER_TABS) { this.tabs.add(tab.id, { label: tab.label, @@ -529,6 +555,9 @@ export class UserLayer extends RefCounted { restoreState(specification: any) { this.tool.restoreState(specification[TOOL_JSON_KEY]); + this.sourceAccordionState.restoreState( + specification[SOURCE_ACCORDION_JSON_KEY], + ); this.panels.restoreState(specification); this.localCoordinateSpace.restoreState( specification[LOCAL_COORDINATE_SPACE_JSON_KEY], @@ -612,6 +641,7 @@ export class UserLayer extends RefCounted { [LOCAL_POSITION_JSON_KEY]: this.localPosition.toJSON(), [LOCAL_VELOCITY_JSON_KEY]: this.localVelocity.toJSON(), [PICK_JSON_KEY]: this.pick.toJSON(), + [SOURCE_ACCORDION_JSON_KEY]: this.sourceAccordionState.toJSON(), ...this.panels.toJSON(), }; } diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index acc3a1646c..57df31461b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -34,7 +34,14 @@ import { import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; -import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; +import { + APPEARANCE_SECTION_JSON_KEY, + MESH_SECTION_JSON_KEY, + registerLayerControls, + SKELETON_SECTION_JSON_KEY, + SLICE_SECTION_JSON_KEY, + VISIBILITY_SECTION_JSON_KEY, +} from "#src/layer/segmentation/layer_controls.js"; import { MeshLayer, MeshSource, @@ -130,9 +137,12 @@ import { } from "#src/util/json.js"; import { Signal } from "#src/util/signal.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; +import { AccordionState } from "#src/widget/accordion.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import { registerLayerShaderControlsTool } from "#src/widget/shader_controls.js"; +export const SEGMENTATION_RENDERING_ACCORDION_JSON_KEY = "renderingAccordion"; + const MAX_LAYER_BAR_UI_INDICATOR_COLORS = 6; export class SegmentationUserLayerGroupState @@ -626,6 +636,32 @@ export class SegmentationUserLayer extends Base { x === undefined ? undefined : parseUint64(x), ); + renderingAccordionState = new AccordionState({ + accordionJsonKey: SEGMENTATION_RENDERING_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: VISIBILITY_SECTION_JSON_KEY, + displayName: "Visibility", + }, + { + jsonKey: APPEARANCE_SECTION_JSON_KEY, + displayName: "Appearance", + }, + { + jsonKey: SLICE_SECTION_JSON_KEY, + displayName: "Slice 2D", + }, + { + jsonKey: MESH_SECTION_JSON_KEY, + displayName: "Mesh 3D", + }, + { + jsonKey: SKELETON_SECTION_JSON_KEY, + displayName: "Skeletons", + }, + ], + }); + constructor(managedLayer: Borrowed) { super(managedLayer); this.codeVisible.changed.add(this.specificationChanged.dispatch); @@ -689,6 +725,9 @@ export class SegmentationUserLayer extends Base { this.displayState.linkedSegmentationGroup.changed.add(() => this.updateDataSubsourceActivations(), ); + this.renderingAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.tabs.add("rendering", { label: "Render", order: -100, @@ -1031,6 +1070,9 @@ export class SegmentationUserLayer extends Base { this.displayState.segmentationColorGroupState.value.restoreState( specification, ); + this.renderingAccordionState.restoreState( + specification[SEGMENTATION_RENDERING_ACCORDION_JSON_KEY], + ); } toJSON() { @@ -1080,6 +1122,8 @@ export class SegmentationUserLayer extends Base { this.displayState.segmentationColorGroupState.value.toJSON(), ); } + x[SEGMENTATION_RENDERING_ACCORDION_JSON_KEY] = + this.renderingAccordionState.toJSON(); return x; } diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 8bcc268fa2..9f145fdd74 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -1,4 +1,4 @@ -import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { type SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; @@ -11,11 +11,18 @@ import { fixedColorLayerControl, } from "#src/widget/segmentation_color_mode.js"; +export const VISIBILITY_SECTION_JSON_KEY = "visibilityExpanded"; +export const APPEARANCE_SECTION_JSON_KEY = "appearanceExpanded"; +export const SLICE_SECTION_JSON_KEY = "sliceRenderingExpanded"; +export const MESH_SECTION_JSON_KEY = "meshRenderingExpanded"; +export const SKELETON_SECTION_JSON_KEY = "skeletonsExpanded"; + export const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Color seed", title: "Color segments based on a hash of their id", toolJson: json_keys.COLOR_SEED_JSON_KEY, + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...colorSeedLayerControl(), }, { @@ -23,12 +30,14 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ title: "Use a fixed color for all segments without an explicitly-specified color", toolJson: json_keys.SEGMENT_DEFAULT_COLOR_JSON_KEY, + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...fixedColorLayerControl(), }, { label: "Saturation", toolJson: json_keys.SATURATION_JSON_KEY, title: "Saturation of segment colors", + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.saturation })), }, { @@ -36,6 +45,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ toolJson: json_keys.SELECTED_ALPHA_JSON_KEY, isValid: (layer) => layer.has2dLayer, title: "Opacity in cross-section views of segments that are selected", + sectionKey: SLICE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.selectedAlpha, })), @@ -45,6 +55,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ toolJson: json_keys.NOT_SELECTED_ALPHA_JSON_KEY, isValid: (layer) => layer.has2dLayer, title: "Opacity in cross-section views of segments that are not selected", + sectionKey: SLICE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.notSelectedAlpha, })), @@ -53,6 +64,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ label: "Resolution (slice)", toolJson: json_keys.CROSS_SECTION_RENDER_SCALE_JSON_KEY, isValid: (layer) => layer.has2dLayer, + sectionKey: SLICE_SECTION_JSON_KEY, ...renderScaleLayerControl((layer) => ({ histogram: layer.sliceViewRenderScaleHistogram, target: layer.sliceViewRenderScaleTarget, @@ -62,6 +74,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ label: "Resolution (mesh)", toolJson: json_keys.MESH_RENDER_SCALE_JSON_KEY, isValid: (layer) => layer.has3dLayer, + sectionKey: MESH_SECTION_JSON_KEY, ...renderScaleLayerControl((layer) => ({ histogram: layer.displayState.renderScaleHistogram, target: layer.displayState.renderScaleTarget, @@ -72,6 +85,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ toolJson: json_keys.OBJECT_ALPHA_JSON_KEY, isValid: (layer) => layer.has3dLayer, title: "Opacity of meshes and skeletons", + sectionKey: MESH_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.objectAlpha, })), @@ -82,6 +96,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ isValid: (layer) => layer.has3dLayer, title: "Set to a non-zero value to increase transparency of object faces perpendicular to view direction", + sectionKey: MESH_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.silhouetteRendering, options: { min: 0, max: maxSilhouettePower, step: 0.1 }, @@ -91,24 +106,28 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ label: "Hide segment ID 0", toolJson: json_keys.HIDE_SEGMENT_ZERO_JSON_KEY, title: "Disallow selection and display of segment id 0", + sectionKey: VISIBILITY_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.hideSegmentZero), }, { label: "Base segment coloring", toolJson: json_keys.BASE_SEGMENT_COLORING_JSON_KEY, title: "Color base segments individually", + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.baseSegmentColoring), }, { label: "Show all by default", title: "Show all segments if none are selected", toolJson: json_keys.IGNORE_NULL_VISIBLE_SET_JSON_KEY, + sectionKey: VISIBILITY_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.ignoreNullVisibleSet), }, { label: "Highlight on hover", toolJson: json_keys.HOVER_HIGHLIGHT_JSON_KEY, title: "Highlight the segment under the mouse pointer", + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.hoverHighlight), }, ...getViewSpecificSkeletonRenderingControl("2d"), diff --git a/src/ui/annotations.ts b/src/ui/annotations.ts index d86c3049e5..40e50730b7 100644 --- a/src/ui/annotations.ts +++ b/src/ui/annotations.ts @@ -100,6 +100,7 @@ import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { NullarySignal, Signal } from "#src/util/signal.js"; import * as vector from "#src/util/vector.js"; +import { AccordionState, AccordionTab } from "#src/widget/accordion.js"; import { makeAddButton } from "#src/widget/add_button.js"; import { ColorWidget } from "#src/widget/color.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; @@ -230,7 +231,7 @@ interface AnnotationLayerViewAttachedState { listOffset: number; } -export class AnnotationLayerView extends Tab { +export class AnnotationLayerView extends AccordionTab { private previousSelectedState: | { annotationId: string; @@ -374,8 +375,9 @@ export class AnnotationLayerView extends Tab { constructor( public layer: Borrowed, public displayState: AnnotationDisplayState, + public annotationAccordionState: AccordionState, ) { - super(); + super(annotationAccordionState); this.element.classList.add("neuroglancer-annotation-layer-view"); this.selectedAnnotationState = makeCachedLazyDerivedWatchableValue( (selectionState, pin) => { @@ -472,7 +474,7 @@ export class AnnotationLayerView extends Tab { mutableControls.appendChild(ellipsoidButton); const helpIcon = makeIcon({ title: - "The left icons allow you to select the type of the anotation. Color and other display settings are available in the 'Rendering' tab.", + "The left icons allow you to select the type of the annotation. Color and other display settings are available in the 'Rendering' tab.", svg: svg_help, clickable: false, }); @@ -480,12 +482,12 @@ export class AnnotationLayerView extends Tab { mutableControls.appendChild(helpIcon); toolbox.appendChild(mutableControls); - this.element.appendChild(toolbox); + this.appendChild(toolbox); - this.element.appendChild(this.headerRow); + this.appendChild(this.headerRow); const { virtualList } = this; virtualList.element.classList.add("neuroglancer-annotation-list"); - this.element.appendChild(virtualList.element); + this.appendChild(virtualList.element); this.virtualList.element.addEventListener("mouseleave", () => { this.displayState.hoverState.value = undefined; }); @@ -984,7 +986,11 @@ export class AnnotationTab extends Tab { constructor(public layer: Borrowed) { super(); this.layerView = this.registerDisposer( - new AnnotationLayerView(layer, layer.annotationDisplayState), + new AnnotationLayerView( + layer, + layer.annotationDisplayState, + layer.annotationAccordionState, + ), ); const { element } = this; @@ -1589,12 +1595,35 @@ function makeRelatedSegmentList( } const ANNOTATION_COLOR_JSON_KEY = "annotationColor"; +const ANNOTATION_ACCORDION_JSON_KEY = "annotationsAccordion"; +export const ANNOTATION_SECTION_JSON_KEY = "annotationsExpanded"; +export const RELATED_SEGMENT_SECTION_JSON_KEY = "relatedSegmentsExpanded"; +export const SPACING_SECTION_JSON_KEY = "spacingExpanded"; export function UserLayerWithAnnotationsMixin< TBase extends { new (...args: any[]): UserLayer }, >(Base: TBase) { abstract class C extends Base implements UserLayerWithAnnotations { annotationStates = this.registerDisposer(new MergedAnnotationStates()); annotationDisplayState = new AnnotationDisplayState(); + annotationAccordionState = new AccordionState({ + accordionJsonKey: ANNOTATION_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: SPACING_SECTION_JSON_KEY, + displayName: "Spacing", + }, + { + jsonKey: RELATED_SEGMENT_SECTION_JSON_KEY, + displayName: "Related segments", + }, + { + jsonKey: ANNOTATION_SECTION_JSON_KEY, + displayName: "Annotations", + defaultExpanded: true, + isDefaultKey: true, + }, + ], + }); annotationCrossSectionRenderScaleHistogram = new RenderScaleHistogram(); annotationCrossSectionRenderScaleTarget = trackableRenderScaleTarget(8); annotationProjectionRenderScaleHistogram = new RenderScaleHistogram(); @@ -1612,6 +1641,9 @@ export function UserLayerWithAnnotationsMixin< this.annotationDisplayState.shaderControls.changed.add( this.specificationChanged.dispatch, ); + this.annotationAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.tabs.add("annotations", { label: "Annotations", order: 10, @@ -1672,6 +1704,9 @@ export function UserLayerWithAnnotationsMixin< this.annotationDisplayState.color.restoreState( specification[ANNOTATION_COLOR_JSON_KEY], ); + this.annotationAccordionState.restoreState( + specification[ANNOTATION_ACCORDION_JSON_KEY], + ); } captureSelectionState( @@ -2160,6 +2195,7 @@ export function UserLayerWithAnnotationsMixin< toJSON() { const x = super.toJSON(); x[ANNOTATION_COLOR_JSON_KEY] = this.annotationDisplayState.color.toJSON(); + x[ANNOTATION_ACCORDION_JSON_KEY] = this.annotationAccordionState.toJSON(); return x; } } diff --git a/src/ui/layer_data_sources_tab.css b/src/ui/layer_data_sources_tab.css index a18860239f..cea029979f 100644 --- a/src/ui/layer_data_sources_tab.css +++ b/src/ui/layer_data_sources_tab.css @@ -24,7 +24,6 @@ display: flex; flex-direction: column; flex: 1; - height: 0px; z-index: 2; } diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index 7b53214290..cbc252343d 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -24,6 +24,7 @@ import type { UserLayer, UserLayerConstructor } from "#src/layer/index.js"; import { changeLayerName, changeLayerType, + CREATE_SECTION_JSON_KEY, makeLayer, NewUserLayer, USER_LAYER_TABS, @@ -53,6 +54,8 @@ import { import type { MessageList } from "#src/util/message_list.js"; import { MessageSeverity } from "#src/util/message_list.js"; import type { ProgressListener } from "#src/util/progress_listener.js"; +import type { AccordionState } from "#src/widget/accordion.js"; +import { AccordionTab } from "#src/widget/accordion.js"; import { makeAddButton } from "#src/widget/add_button.js"; import { CoordinateSpaceTransformWidget } from "#src/widget/coordinate_transform.js"; import type { @@ -64,7 +67,6 @@ import { makeCompletionElementWithDescription, } from "#src/widget/multiline_autocomplete.js"; import { ProgressListenerWidget } from "#src/widget/progress_listener.js"; -import { Tab } from "#src/widget/tab_view.js"; const dataSourceUrlSyntaxHighlighter: SyntaxHighlighter = { splitPattern: /\|?[^|:/_]*(?:[:/_]+)?/g, @@ -420,7 +422,7 @@ function changeLayerTypeToDetected(userLayer: UserLayer) { return false; } -export class LayerDataSourcesTab extends Tab { +export class LayerDataSourcesTab extends AccordionTab { generation = -1; private sourceViews = new Map(); private addDataSourceIcon = makeAddButton({ @@ -432,8 +434,11 @@ export class LayerDataSourcesTab extends Tab { private dataSourcesContainer = document.createElement("div"); private reRender: DebouncedFunction; - constructor(public layer: Borrowed) { - super(); + constructor( + public layer: Borrowed, + protected accordionState: AccordionState, + ) { + super(accordionState); const { element, dataSourcesContainer } = this; element.classList.add("neuroglancer-layer-data-sources-tab"); dataSourcesContainer.classList.add( @@ -448,7 +453,7 @@ export class LayerDataSourcesTab extends Tab { if (view === undefined) return; view.urlInput.inputElement.focus(); }); - element.appendChild(this.dataSourcesContainer); + this.appendChild(this.dataSourcesContainer); if (layer instanceof NewUserLayer) { const { layerTypeDetection, layerTypeElement, multiChannelLayerCreate } = this; @@ -459,7 +464,7 @@ export class LayerDataSourcesTab extends Tab { layerTypeDetection.appendChild(document.createTextNode("Create as ")); layerTypeDetection.appendChild(layerTypeElement); layerTypeDetection.appendChild(document.createTextNode(" layer")); - element.appendChild(layerTypeDetection); + this.appendChild(layerTypeDetection, CREATE_SECTION_JSON_KEY); layerTypeDetection.classList.add( "neuroglancer-layer-data-sources-tab-type-detection", ); @@ -492,7 +497,10 @@ export class LayerDataSourcesTab extends Tab { }); multiChannelLayerCreate.style.display = "none"; multiChannelLayerCreate.style.marginTop = "0.5em"; - element.appendChild(multiChannelLayerCreate); + this.appendChild(multiChannelLayerCreate, CREATE_SECTION_JSON_KEY); + + // Initially hide the section since both buttons start hidden + this.hideSection(CREATE_SECTION_JSON_KEY); } const reRender = (this.reRender = animationFrameDebounce(() => this.updateView(), @@ -522,14 +530,15 @@ export class LayerDataSourcesTab extends Tab { const { layerTypeElement } = this; layerTypeElement.textContent = layerConstructor.type; layerTypeDetection.title = - "Click here or press enter in the data source URL input box to create as " + - `${layerConstructor.type} layer`; + "Click here to create as " + `${layerConstructor.type} layer`; layerTypeDetection.style.display = ""; multiChannelLayerCreate.style.display = layerConstructor.type === "image" ? "" : "none"; + this.showSection(CREATE_SECTION_JSON_KEY); } else { layerTypeDetection.style.display = "none"; multiChannelLayerCreate.style.display = "none"; + this.hideSection(CREATE_SECTION_JSON_KEY); } } @@ -593,5 +602,5 @@ USER_LAYER_TABS.push({ id: "source", label: "Source", order: -100, - getter: (layer) => new LayerDataSourcesTab(layer), + getter: (layer) => new LayerDataSourcesTab(layer, layer.sourceAccordionState), }); diff --git a/src/ui/segmentation_display_options_tab.ts b/src/ui/segmentation_display_options_tab.ts index 7bb6e9fc80..ee3261a0d7 100644 --- a/src/ui/segmentation_display_options_tab.ts +++ b/src/ui/segmentation_display_options_tab.ts @@ -14,10 +14,16 @@ * limitations under the License. */ -import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { type SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { SKELETON_RENDERING_SHADER_CONTROL_TOOL_ID } from "#src/layer/segmentation/json_keys.js"; -import { LAYER_CONTROLS } from "#src/layer/segmentation/layer_controls.js"; +import { + APPEARANCE_SECTION_JSON_KEY, + LAYER_CONTROLS, + VISIBILITY_SECTION_JSON_KEY, + SKELETON_SECTION_JSON_KEY, +} from "#src/layer/segmentation/layer_controls.js"; import { Overlay } from "#src/overlay.js"; +import { AccordionTab } from "#src/widget/accordion.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; import { addLayerControlToOptionsTab } from "#src/widget/layer_control.js"; import { LinkedLayerGroupWidget } from "#src/widget/linked_layer.js"; @@ -26,7 +32,6 @@ import { ShaderCodeWidget, } from "#src/widget/shader_code_widget.js"; import { ShaderControls } from "#src/widget/shader_controls.js"; -import { Tab } from "#src/widget/tab_view.js"; function makeSkeletonShaderCodeWidget(layer: SegmentationUserLayer) { return new ShaderCodeWidget({ @@ -37,9 +42,9 @@ function makeSkeletonShaderCodeWidget(layer: SegmentationUserLayer) { }); } -export class DisplayOptionsTab extends Tab { +export class DisplayOptionsTab extends AccordionTab { constructor(public layer: SegmentationUserLayer) { - super(); + super(layer.renderingAccordionState); const { element } = this; element.classList.add("neuroglancer-segmentation-rendering-tab"); @@ -49,7 +54,7 @@ export class DisplayOptionsTab extends Tab { new LinkedLayerGroupWidget(layer.displayState.linkedSegmentationGroup), ); widget.label.textContent = "Linked to: "; - element.appendChild(widget.element); + this.appendChild(widget.element, VISIBILITY_SECTION_JSON_KEY); } // Linked segmentation control @@ -60,12 +65,13 @@ export class DisplayOptionsTab extends Tab { ), ); widget.label.textContent = "Colors linked to: "; - element.appendChild(widget.element); + this.appendChild(widget.element, APPEARANCE_SECTION_JSON_KEY); } for (const control of LAYER_CONTROLS) { - element.appendChild( + this.appendChild( addLayerControlToOptionsTab(this, layer, this.visibility, control), + control.sectionKey, ); } @@ -108,7 +114,19 @@ export class DisplayOptionsTab extends Tab { this.visibility, ), ); - element.appendChild(skeletonControls.element); + this.appendChild( + skeletonControls.element, + SKELETON_SECTION_JSON_KEY, + !this.layer.hasSkeletonsLayer.value, + ); + this.registerDisposer( + this.layer.hasSkeletonsLayer.changed.add(() => { + this.setSectionHidden( + SKELETON_SECTION_JSON_KEY, + !this.layer.hasSkeletonsLayer.value, + ); + }), + ); } } diff --git a/src/widget/accordion.css b/src/widget/accordion.css new file mode 100644 index 0000000000..587b2e42e3 --- /dev/null +++ b/src/widget/accordion.css @@ -0,0 +1,52 @@ +.neuroglancer-accordion-item { + border-bottom: 1px solid #ddd; +} + +.neuroglancer-accordion-item[data-expanded="true"] + .neuroglancer-accordion-body { + display: block; /* Show when expanded */ +} + +.neuroglancer-accordion-item[data-expanded="true"] + .neuroglancer-accordion-header + .neuroglancer-accordion-chevron + svg { + transform: rotate(180deg); /* Rotate chevron when expanded */ +} + +.neuroglancer-accordion-chevron { + margin-top: 4px; + display: inline-flex; + align-items: center; +} + +.neuroglancer-accordion-chevron svg { + width: 20px; + height: 20px; +} + +.neuroglancer-accordion-chevron svg path { + fill: rgb(153, 156, 160); /* Chevron color */ +} + +.neuroglancer-accordion-item[data-expanded="false"] + .neuroglancer-accordion-body { + display: none; /* Hide when collapsed */ +} + +.neuroglancer-accordion-header { + padding: 10px; + cursor: pointer; + justify-content: space-between; + display: flex; +} + +.neuroglancer-accordion-header:hover { + background-color: #141414; +} + +.neuroglancer-accordion-body { + padding: 10px; + overflow-x: auto; + overflow-y: auto; +} diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts new file mode 100644 index 0000000000..6228732990 --- /dev/null +++ b/src/widget/accordion.ts @@ -0,0 +1,261 @@ +import { TrackableBoolean } from "#src/trackable_boolean.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import svg_chevron_down from "ikonate/icons/chevron-down.svg?raw"; +import { RefCounted } from "#src/util/disposable.js"; +import { NullarySignal } from "#src/util/signal.js"; +import "#src/widget/accordion.css"; +import { Tab } from "#src/widget/tab_view.js"; + +const ENABLE_ACCORDIONS = true; + +export interface AccordionOptions { + accordionJsonKey: string; + sections: AccordionSectionOptions[]; +} + +interface AccordionSectionOptions { + jsonKey: string; + displayName: string; + defaultExpanded?: boolean; + isDefaultKey?: boolean; +} + +interface AccordionSection { + name: string; + jsonKey: string; + container: HTMLElement; + header: HTMLElement; + body: HTMLElement; +} + +export class AccordionSectionState extends RefCounted { + isExpanded: WatchableValueInterface; + + constructor( + public jsonKey: string, + private defaultExpanded = false, + onChangeCallback: () => void, + ) { + super(); + this.isExpanded = new TrackableBoolean(defaultExpanded, defaultExpanded); + this.registerDisposer(this.isExpanded.changed.add(onChangeCallback)); + } + + toJSON() { + if (this.isExpanded.value === this.defaultExpanded) return undefined; + return { [this.jsonKey]: this.isExpanded.value }; + } +} + +export class AccordionState extends RefCounted { + sectionStates: AccordionSectionState[] = []; + specificationChanged = new NullarySignal(); + + constructor(public accordionOptions: AccordionOptions) { + super(); + for (const sectionOptions of accordionOptions.sections) { + this.getOrCreateSectionState(sectionOptions); + } + } + + getOrCreateSectionState(sectionOptions: AccordionSectionOptions) { + const { jsonKey, defaultExpanded } = sectionOptions; + let sectionState = this.getSectionState(jsonKey); + if (sectionState === undefined) { + sectionState = this.registerDisposer( + new AccordionSectionState( + jsonKey, + defaultExpanded, + this.specificationChanged.dispatch, + ), + ); + this.sectionStates.push(sectionState); + } + return sectionState; + } + + getSectionState(jsonKey: string): AccordionSectionState | undefined { + return this.sectionStates.find((s) => s.jsonKey === jsonKey); + } + + setSectionExpanded(jsonKey: string, expand?: boolean): void { + const section = this.getSectionState(jsonKey); + if (section !== undefined) { + section.isExpanded.value = expand ?? !section.isExpanded.value; + } + } + + restoreState(obj: unknown) { + if (obj === undefined || obj === null || typeof obj !== "object") { + return; + } + for (const [jsonKey, isExpanded] of Object.entries(obj)) { + if (typeof isExpanded === "boolean") { + this.setSectionExpanded(jsonKey, isExpanded); + } + } + } + + toJSON() { + if (!ENABLE_ACCORDIONS) return undefined; + const sectionsData = this.sectionStates + .map((section) => section.toJSON()) + .filter((data) => data !== undefined); + + return sectionsData.length === 0 + ? undefined + : Object.assign({}, ...sectionsData); + } +} + +export class AccordionTab extends Tab { + sections: AccordionSection[] = []; + defaultKey: string | undefined; + + constructor(protected accordionState: AccordionState) { + super(); + const options = accordionState.accordionOptions; + this.element.classList.add("neuroglancer-accordion"); + this.registerDisposer( + this.accordionState.specificationChanged.add(() => + this.updateSectionsExpanded(), + ), + ); + options.sections.forEach((option) => { + this.createAccordionSection(option); + }); + if (this.defaultKey === undefined && options.sections.length > 0) { + this.defaultKey = options.sections[0].jsonKey; + } + this.updateSectionsExpanded(); + if (!ENABLE_ACCORDIONS) { + this.setAccordionHeadersHidden(true); + } + } + + private setSectionExpanded(jsonKey: string, expand?: boolean): void { + this.accordionState.setSectionExpanded(jsonKey, expand); + } + + private updateSectionsExpanded() { + this.accordionState.sectionStates.forEach((state) => { + const section = this.getSectionByKey(state.jsonKey); + if (section === undefined) return; + section.container.dataset.expanded = String(state.isExpanded.value); + section.header.setAttribute( + "aria-expanded", + String(state.isExpanded.value), + ); + }); + } + + private createAccordionSection( + option: AccordionSectionOptions, + ): AccordionSection | undefined { + const newSection: AccordionSection = { + name: option.displayName, + jsonKey: option.jsonKey, + container: document.createElement("div"), + header: document.createElement("div"), + body: document.createElement("div"), + }; + this.sections.push(newSection); + const { container, header, body } = newSection; + container.classList.add("neuroglancer-accordion-item"); + body.classList.add("neuroglancer-accordion-body"); + header.classList.add("neuroglancer-accordion-header"); + container.appendChild(newSection.header); + container.appendChild(newSection.body); + this.element.appendChild(container); + + const chevron = document.createElement("span"); + chevron.classList.add("neuroglancer-accordion-chevron"); + chevron.innerHTML = svg_chevron_down; + const headerText = document.createElement("span"); + headerText.classList.add("neuroglancer-accordion-header-text"); + headerText.textContent = option.displayName; + header.appendChild(headerText); + header.appendChild(chevron); + + container.style.display = "none"; + container.dataset.expanded = String(option.defaultExpanded ?? false); + + if (option.isDefaultKey) { + this.defaultKey = option.jsonKey; + } + + this.registerEventListener(newSection.header, "click", () => + this.setSectionExpanded(option.jsonKey), + ); + + // Usually, the state is pre-propulated with all the relevant sections. + // However, because appendChild is public and can be called with + // a jsonKey that is not in the initial accordionOptions, we need to + // add the section into the state if that happens + // This state wouldn't get properly restored if that occurs, + // but in case there is some unforeseen section added, at least + // the controls to expand/collapse it will still work because of this + this.accordionState.getOrCreateSectionState(option); + return newSection; + } + + private getSectionByKey( + jsonKey: string | undefined, + ): AccordionSection | undefined { + return this.sections.find((e) => e.jsonKey === jsonKey); + } + + private getSectionWithFallback(jsonKey?: string): AccordionSection { + const section = + this.getSectionByKey(jsonKey ?? this.defaultKey) ?? + this.getSectionByKey(this.defaultKey); + if (section === undefined) { + throw new Error( + `Accordion section with key "${jsonKey ?? this.defaultKey}" not found.`, + ); + } + return section; + } + + appendChild(content: HTMLElement, jsonKey?: string, hidden?: boolean): void { + const section = this.getSectionWithFallback(jsonKey); + section.body.appendChild(content); + if (!hidden) section.container.style.display = ""; + } + + /** + * Set the visibility of the section with the given jsonKey. + * This is different to expanding/collapsing the section. + */ + setSectionHidden(jsonKey: string, hidden: boolean): void { + const section = this.getSectionByKey(jsonKey); + if (section !== undefined) { + section.container.style.display = hidden ? "none" : ""; + } + } + + /** + * Show the section with the given jsonKey. + * This is different to expanding the section, it is only about visibility. + */ + showSection(jsonKey: string): void { + this.setSectionHidden(jsonKey, false); + } + + /** + * Hide the section with the given jsonKey. + * This is different to collapsing the section, it is only about visibility. + */ + hideSection(jsonKey: string): void { + this.setSectionHidden(jsonKey, true); + } + + setAccordionHeadersHidden(hidden: boolean): void { + this.sections.forEach((section) => { + section.header.style.display = hidden ? "none" : ""; + if (hidden) { + this.setSectionExpanded(section.jsonKey, true); + } + }); + } +} diff --git a/src/widget/layer_control.ts b/src/widget/layer_control.ts index 7497ee3006..67172e53a8 100644 --- a/src/widget/layer_control.ts +++ b/src/widget/layer_control.ts @@ -37,6 +37,7 @@ export interface LayerControlLabelOptions< title?: string; toolDescription?: string; toolJson: any; + sectionKey?: string; isValid?: (layer: LayerType) => WatchableValueInterface; } From 639e6e2035e66edc865e225f52fa5c28cee22837 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 2 Sep 2025 18:12:33 +0200 Subject: [PATCH 02/32] fix: correct paddings --- src/widget/accordion.css | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/widget/accordion.css b/src/widget/accordion.css index 587b2e42e3..c8f3ba13ac 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -15,7 +15,6 @@ } .neuroglancer-accordion-chevron { - margin-top: 4px; display: inline-flex; align-items: center; } @@ -23,10 +22,7 @@ .neuroglancer-accordion-chevron svg { width: 20px; height: 20px; -} - -.neuroglancer-accordion-chevron svg path { - fill: rgb(153, 156, 160); /* Chevron color */ + fill: rgb(255, 255, 255); /* Chevron color */ } .neuroglancer-accordion-item[data-expanded="false"] @@ -35,18 +31,14 @@ } .neuroglancer-accordion-header { - padding: 10px; + padding: 8px 2px; cursor: pointer; justify-content: space-between; display: flex; } -.neuroglancer-accordion-header:hover { - background-color: #141414; -} - .neuroglancer-accordion-body { - padding: 10px; + padding: 2px; overflow-x: auto; overflow-y: auto; } From 6e5cd58db0bdfdd733ec34cc91822cde06590fe0 Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Fri, 17 Oct 2025 13:32:35 +0200 Subject: [PATCH 03/32] NGLASS-1051 some style change to accordions --- src/ui/side_panel.css | 4 ++-- src/widget/accordion.css | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/side_panel.css b/src/ui/side_panel.css index 0225c4063c..cb354505bd 100644 --- a/src/ui/side_panel.css +++ b/src/ui/side_panel.css @@ -57,7 +57,7 @@ width: 1px; background-color: #333; background-clip: content-box; - padding-right: 2px; - padding-left: 2px; + /* padding-right: 2px; + padding-left: 2px; */ cursor: col-resize; } diff --git a/src/widget/accordion.css b/src/widget/accordion.css index c8f3ba13ac..a52569747f 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -34,11 +34,12 @@ padding: 8px 2px; cursor: pointer; justify-content: space-between; + align-items: center; display: flex; } .neuroglancer-accordion-body { - padding: 2px; + padding: 8px; overflow-x: auto; overflow-y: auto; } From e146b8fa8d17cb9bf1b93d04840264b2b824cead Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Mon, 20 Oct 2025 14:28:59 +0200 Subject: [PATCH 04/32] NGLASS-1051 fix paddings and color of accordion content and header --- src/widget/accordion.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/widget/accordion.css b/src/widget/accordion.css index a52569747f..1710ebf9cf 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -22,7 +22,7 @@ .neuroglancer-accordion-chevron svg { width: 20px; height: 20px; - fill: rgb(255, 255, 255); /* Chevron color */ + fill: rgba(255, 255, 255, 0.80); /* Chevron color */ } .neuroglancer-accordion-item[data-expanded="false"] @@ -38,8 +38,13 @@ display: flex; } +.neuroglancer-accordion-header-text { + color: rgba(255, 255, 255, 0.80); +} + .neuroglancer-accordion-body { - padding: 8px; + padding: 8px 2px; overflow-x: auto; overflow-y: auto; + padding-top: 0; } From 740d51f77f780b45aff6b9eef16b7d8dc95db4d8 Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Wed, 22 Oct 2025 11:24:12 +0200 Subject: [PATCH 05/32] NGLASS-1051 delete unnecessary code --- src/ui/side_panel.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ui/side_panel.css b/src/ui/side_panel.css index cb354505bd..54d0f070cf 100644 --- a/src/ui/side_panel.css +++ b/src/ui/side_panel.css @@ -57,7 +57,5 @@ width: 1px; background-color: #333; background-clip: content-box; - /* padding-right: 2px; - padding-left: 2px; */ cursor: col-resize; } From 5770999bce37574eb818e4183e587105fe200a73 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 20 Feb 2026 09:40:23 +0100 Subject: [PATCH 06/32] fix: place skeleton rendering in right section --- src/layer/segmentation/layer_controls.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 9f145fdd74..c6bbc4db93 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -143,6 +143,7 @@ function getViewSpecificSkeletonRenderingControl( { label: `Skeleton mode (${viewName})`, toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.mode${viewName}`, + sectionKey: SKELETON_SECTION_JSON_KEY, isValid: (layer) => layer.hasSkeletonsLayer, ...enumLayerControl( (layer) => @@ -154,6 +155,7 @@ function getViewSpecificSkeletonRenderingControl( { label: `Line width (${viewName})`, toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.lineWidth${viewName}`, + sectionKey: SKELETON_SECTION_JSON_KEY, isValid: (layer) => layer.hasSkeletonsLayer, toolDescription: `Skeleton line width (${viewName})`, title: `Skeleton line width (${viewName})`, From f7c9175b883f4c3b772077c750263dba6a9f4953 Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Thu, 5 Mar 2026 11:02:01 +0100 Subject: [PATCH 07/32] NGLASS-1141 make annotation property value to overflow --- src/ui/annotations.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/annotations.css b/src/ui/annotations.css index 39b3b41bfc..e63ea42a53 100644 --- a/src/ui/annotations.css +++ b/src/ui/annotations.css @@ -262,6 +262,9 @@ div.neuroglancer-annotation-details-description { font-family: monospace; font-size: medium; flex: 1; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } input.neuroglancer-segment-list-entry-id { From 2c35c087bdd1be32348b2acd4afd4eb4b0185023 Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Fri, 6 Mar 2026 13:02:07 +0100 Subject: [PATCH 08/32] NGLASS-1141 change accordion visibility using variable --- rspack.config.ts | 1 + src/widget/accordion.css | 8 ++++++++ src/widget/accordion.ts | 15 +++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index e7c0c70e71..6a8f61cdf1 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -110,6 +110,7 @@ export default defineConfig((env, args) => { NEUROGLANCER_BRAINMAPS_CLIENT_ID: JSON.stringify( "639403125587-4k5hgdfumtrvur8v48e3pr7oo91d765k.apps.googleusercontent.com", ), + NEUROGLANCER_USE_ACCORDIONS: process.env.NEUROGLANCER_USE_ACCORDIONS === 'true', // NEUROGLANCER_CREDIT_LINK: JSON.stringify({url: '...', text: '...'}), // NEUROGLANCER_DEFAULT_STATE_FRAGMENT: JSON.stringify('gs://bucket/state.json'), diff --git a/src/widget/accordion.css b/src/widget/accordion.css index 1710ebf9cf..ff4a315f0e 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -48,3 +48,11 @@ overflow-y: auto; padding-top: 0; } + +.neuroglancer-accordion-item.no-border { + border-bottom: none; +} + +.neuroglancer-accordion-item.no-border .neuroglancer-accordion-body { + padding: 0; +} \ No newline at end of file diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 6228732990..f4ab0c18ed 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -6,7 +6,7 @@ import { NullarySignal } from "#src/util/signal.js"; import "#src/widget/accordion.css"; import { Tab } from "#src/widget/tab_view.js"; -const ENABLE_ACCORDIONS = true; +declare let NEUROGLANCER_USE_ACCORDIONS: boolean | undefined; export interface AccordionOptions { accordionJsonKey: string; @@ -33,7 +33,7 @@ export class AccordionSectionState extends RefCounted { constructor( public jsonKey: string, - private defaultExpanded = false, + private defaultExpanded = true, onChangeCallback: () => void, ) { super(); @@ -97,7 +97,8 @@ export class AccordionState extends RefCounted { } toJSON() { - if (!ENABLE_ACCORDIONS) return undefined; + const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; + if (!useAccordions) return undefined; const sectionsData = this.sectionStates .map((section) => section.toJSON()) .filter((data) => data !== undefined); @@ -128,7 +129,8 @@ export class AccordionTab extends Tab { this.defaultKey = options.sections[0].jsonKey; } this.updateSectionsExpanded(); - if (!ENABLE_ACCORDIONS) { + const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; + if (!useAccordions) { this.setAccordionHeadersHidden(true); } } @@ -188,6 +190,11 @@ export class AccordionTab extends Tab { this.setSectionExpanded(option.jsonKey), ); + const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; + if (!useAccordions) { + container.classList.add("no-border"); + } + // Usually, the state is pre-propulated with all the relevant sections. // However, because appendChild is public and can be called with // a jsonKey that is not in the initial accordionOptions, we need to From 9d9ddcc2344a20356b8c55494cee78e8ca09dc6f Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Mon, 9 Mar 2026 11:31:58 +0100 Subject: [PATCH 09/32] NGLASS-1141 update chevron icon title --- src/widget/accordion.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index f4ab0c18ed..e8a8055da0 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -148,6 +148,15 @@ export class AccordionTab extends Tab { "aria-expanded", String(state.isExpanded.value), ); + // Update chevron tooltip + const chevron = section.header.querySelector('.neuroglancer-accordion-chevron'); + if (chevron) { + const title = chevron.querySelector('title'); + if (title) { + title.textContent = state.isExpanded.value ? 'Chevron Up' : 'Chevron Down'; + } + chevron.setAttribute('title', state.isExpanded.value ? 'Chevron Up' : 'Chevron Down'); + } }); } @@ -173,6 +182,13 @@ export class AccordionTab extends Tab { const chevron = document.createElement("span"); chevron.classList.add("neuroglancer-accordion-chevron"); chevron.innerHTML = svg_chevron_down; + // Set initial tooltip + const initialTooltip = option.defaultExpanded ? 'Chevron Up' : 'Chevron Down'; + chevron.setAttribute('title', initialTooltip); + const title = chevron.querySelector('title'); + if (title) { + title.textContent = initialTooltip; + } const headerText = document.createElement("span"); headerText.classList.add("neuroglancer-accordion-header-text"); headerText.textContent = option.displayName; From 19a2ef466c0b2a9772fb9c06a714f88662182d72 Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Thu, 12 Mar 2026 11:20:57 +0100 Subject: [PATCH 10/32] NGLASS-1141 change class name and make default expand value false --- src/widget/accordion.css | 4 ++-- src/widget/accordion.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/widget/accordion.css b/src/widget/accordion.css index ff4a315f0e..fdf44e0e2d 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -49,10 +49,10 @@ padding-top: 0; } -.neuroglancer-accordion-item.no-border { +.neuroglancer-accordion-item.neuroglancer-accordion-no-border { border-bottom: none; } -.neuroglancer-accordion-item.no-border .neuroglancer-accordion-body { +.neuroglancer-accordion-item.neuroglancer-accordion-no-border .neuroglancer-accordion-body { padding: 0; } \ No newline at end of file diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index e8a8055da0..b8d152e085 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -33,7 +33,7 @@ export class AccordionSectionState extends RefCounted { constructor( public jsonKey: string, - private defaultExpanded = true, + private defaultExpanded = false, onChangeCallback: () => void, ) { super(); @@ -208,7 +208,7 @@ export class AccordionTab extends Tab { const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; if (!useAccordions) { - container.classList.add("no-border"); + container.classList.add("neuroglancer-accordion-no-border"); } // Usually, the state is pre-propulated with all the relevant sections. From 836ef2986613f83e57de216bedb64610f4a25330 Mon Sep 17 00:00:00 2001 From: Aiga115 Date: Thu, 12 Mar 2026 12:05:36 +0100 Subject: [PATCH 11/32] NGLASS-1141 make data source tab a regular tab --- src/layer/index.ts | 30 ------------------------------ src/ui/layer_data_sources_tab.ts | 29 ++++++++++------------------- 2 files changed, 10 insertions(+), 49 deletions(-) diff --git a/src/layer/index.ts b/src/layer/index.ts index b0c2611234..f4ea769103 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -109,7 +109,6 @@ import { import type { Trackable } from "#src/util/trackable.js"; import { kEmptyFloat32Vec } from "#src/util/vector.js"; import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; -import { AccordionState } from "#src/widget/accordion.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import type { Tab } from "#src/widget/tab_view.js"; import { TabSpecification } from "#src/widget/tab_view.js"; @@ -123,9 +122,6 @@ const LOCAL_COORDINATE_SPACE_JSON_KEY = "localDimensions"; const SOURCE_JSON_KEY = "source"; const TRANSFORM_JSON_KEY = "transform"; const PICK_JSON_KEY = "pick"; -const SOURCE_ACCORDION_JSON_KEY = "sourceAccordion"; -const DATA_SECTION_JSON_KEY = "sourceExpanded"; -export const CREATE_SECTION_JSON_KEY = "createExpanded"; export interface UserLayerSelectionState { generation: number; @@ -370,24 +366,6 @@ export class UserLayer extends RefCounted { } tabs = this.registerDisposer(new TabSpecification()); - sourceAccordionState = this.registerDisposer( - new AccordionState({ - accordionJsonKey: SOURCE_ACCORDION_JSON_KEY, - sections: [ - { - jsonKey: DATA_SECTION_JSON_KEY, - displayName: "Data sources", - defaultExpanded: true, - isDefaultKey: true, - }, - { - jsonKey: CREATE_SECTION_JSON_KEY, - displayName: "Initial settings", - defaultExpanded: true, - }, - ], - }), - ); panels = new UserLayerSidePanelsState(this); tool = this.registerDisposer(new SelectedLegacyTool(this)); toolBinder: LayerToolBinder; @@ -407,9 +385,6 @@ export class UserLayer extends RefCounted { this.localCoordinateSpaceCombiner.includeDimensionPredicate = isLocalOrChannelDimension; this.tabs.changed.add(this.specificationChanged.dispatch); - this.sourceAccordionState.specificationChanged.add( - this.specificationChanged.dispatch, - ); this.panels.specificationChanged.add(this.specificationChanged.dispatch); this.tool.changed.add(this.specificationChanged.dispatch); this.toolBinder.changed.add(this.specificationChanged.dispatch); @@ -419,7 +394,6 @@ export class UserLayer extends RefCounted { this.dataSourcesChanged.add(this.specificationChanged.dispatch); this.dataSourcesChanged.add(() => this.updateDataSubsourceActivations()); this.messages.changed.add(this.layersChanged.dispatch); - for (const tab of USER_LAYER_TABS) { this.tabs.add(tab.id, { label: tab.label, @@ -561,9 +535,6 @@ export class UserLayer extends RefCounted { restoreState(specification: any) { this.tool.restoreState(specification[TOOL_JSON_KEY]); - this.sourceAccordionState.restoreState( - specification[SOURCE_ACCORDION_JSON_KEY], - ); this.panels.restoreState(specification); this.localCoordinateSpace.restoreState( specification[LOCAL_COORDINATE_SPACE_JSON_KEY], @@ -647,7 +618,6 @@ export class UserLayer extends RefCounted { [LOCAL_POSITION_JSON_KEY]: this.localPosition.toJSON(), [LOCAL_VELOCITY_JSON_KEY]: this.localVelocity.toJSON(), [PICK_JSON_KEY]: this.pick.toJSON(), - [SOURCE_ACCORDION_JSON_KEY]: this.sourceAccordionState.toJSON(), ...this.panels.toJSON(), }; } diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index cbc252343d..7b53214290 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -24,7 +24,6 @@ import type { UserLayer, UserLayerConstructor } from "#src/layer/index.js"; import { changeLayerName, changeLayerType, - CREATE_SECTION_JSON_KEY, makeLayer, NewUserLayer, USER_LAYER_TABS, @@ -54,8 +53,6 @@ import { import type { MessageList } from "#src/util/message_list.js"; import { MessageSeverity } from "#src/util/message_list.js"; import type { ProgressListener } from "#src/util/progress_listener.js"; -import type { AccordionState } from "#src/widget/accordion.js"; -import { AccordionTab } from "#src/widget/accordion.js"; import { makeAddButton } from "#src/widget/add_button.js"; import { CoordinateSpaceTransformWidget } from "#src/widget/coordinate_transform.js"; import type { @@ -67,6 +64,7 @@ import { makeCompletionElementWithDescription, } from "#src/widget/multiline_autocomplete.js"; import { ProgressListenerWidget } from "#src/widget/progress_listener.js"; +import { Tab } from "#src/widget/tab_view.js"; const dataSourceUrlSyntaxHighlighter: SyntaxHighlighter = { splitPattern: /\|?[^|:/_]*(?:[:/_]+)?/g, @@ -422,7 +420,7 @@ function changeLayerTypeToDetected(userLayer: UserLayer) { return false; } -export class LayerDataSourcesTab extends AccordionTab { +export class LayerDataSourcesTab extends Tab { generation = -1; private sourceViews = new Map(); private addDataSourceIcon = makeAddButton({ @@ -434,11 +432,8 @@ export class LayerDataSourcesTab extends AccordionTab { private dataSourcesContainer = document.createElement("div"); private reRender: DebouncedFunction; - constructor( - public layer: Borrowed, - protected accordionState: AccordionState, - ) { - super(accordionState); + constructor(public layer: Borrowed) { + super(); const { element, dataSourcesContainer } = this; element.classList.add("neuroglancer-layer-data-sources-tab"); dataSourcesContainer.classList.add( @@ -453,7 +448,7 @@ export class LayerDataSourcesTab extends AccordionTab { if (view === undefined) return; view.urlInput.inputElement.focus(); }); - this.appendChild(this.dataSourcesContainer); + element.appendChild(this.dataSourcesContainer); if (layer instanceof NewUserLayer) { const { layerTypeDetection, layerTypeElement, multiChannelLayerCreate } = this; @@ -464,7 +459,7 @@ export class LayerDataSourcesTab extends AccordionTab { layerTypeDetection.appendChild(document.createTextNode("Create as ")); layerTypeDetection.appendChild(layerTypeElement); layerTypeDetection.appendChild(document.createTextNode(" layer")); - this.appendChild(layerTypeDetection, CREATE_SECTION_JSON_KEY); + element.appendChild(layerTypeDetection); layerTypeDetection.classList.add( "neuroglancer-layer-data-sources-tab-type-detection", ); @@ -497,10 +492,7 @@ export class LayerDataSourcesTab extends AccordionTab { }); multiChannelLayerCreate.style.display = "none"; multiChannelLayerCreate.style.marginTop = "0.5em"; - this.appendChild(multiChannelLayerCreate, CREATE_SECTION_JSON_KEY); - - // Initially hide the section since both buttons start hidden - this.hideSection(CREATE_SECTION_JSON_KEY); + element.appendChild(multiChannelLayerCreate); } const reRender = (this.reRender = animationFrameDebounce(() => this.updateView(), @@ -530,15 +522,14 @@ export class LayerDataSourcesTab extends AccordionTab { const { layerTypeElement } = this; layerTypeElement.textContent = layerConstructor.type; layerTypeDetection.title = - "Click here to create as " + `${layerConstructor.type} layer`; + "Click here or press enter in the data source URL input box to create as " + + `${layerConstructor.type} layer`; layerTypeDetection.style.display = ""; multiChannelLayerCreate.style.display = layerConstructor.type === "image" ? "" : "none"; - this.showSection(CREATE_SECTION_JSON_KEY); } else { layerTypeDetection.style.display = "none"; multiChannelLayerCreate.style.display = "none"; - this.hideSection(CREATE_SECTION_JSON_KEY); } } @@ -602,5 +593,5 @@ USER_LAYER_TABS.push({ id: "source", label: "Source", order: -100, - getter: (layer) => new LayerDataSourcesTab(layer, layer.sourceAccordionState), + getter: (layer) => new LayerDataSourcesTab(layer), }); From 6aa77399d8acc12338652ec26839d7eebee69504 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 11:05:08 +0200 Subject: [PATCH 12/32] revert: remove unrelated change --- src/ui/annotations.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ui/annotations.css b/src/ui/annotations.css index e63ea42a53..39b3b41bfc 100644 --- a/src/ui/annotations.css +++ b/src/ui/annotations.css @@ -262,9 +262,6 @@ div.neuroglancer-annotation-details-description { font-family: monospace; font-size: medium; flex: 1; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; } input.neuroglancer-segment-list-entry-id { From 19ea43fc55f7bccc78975f3fae77adce0e351502 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 11:06:29 +0200 Subject: [PATCH 13/32] chore: lint and format --- rspack.config.ts | 3 ++- src/widget/accordion.css | 9 +++++---- src/widget/accordion.ts | 40 +++++++++++++++++++++++++++++----------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index 6a8f61cdf1..517e4b9717 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -110,7 +110,8 @@ export default defineConfig((env, args) => { NEUROGLANCER_BRAINMAPS_CLIENT_ID: JSON.stringify( "639403125587-4k5hgdfumtrvur8v48e3pr7oo91d765k.apps.googleusercontent.com", ), - NEUROGLANCER_USE_ACCORDIONS: process.env.NEUROGLANCER_USE_ACCORDIONS === 'true', + NEUROGLANCER_USE_ACCORDIONS: + process.env.NEUROGLANCER_USE_ACCORDIONS === "true", // NEUROGLANCER_CREDIT_LINK: JSON.stringify({url: '...', text: '...'}), // NEUROGLANCER_DEFAULT_STATE_FRAGMENT: JSON.stringify('gs://bucket/state.json'), diff --git a/src/widget/accordion.css b/src/widget/accordion.css index fdf44e0e2d..34c2883fdd 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -22,7 +22,7 @@ .neuroglancer-accordion-chevron svg { width: 20px; height: 20px; - fill: rgba(255, 255, 255, 0.80); /* Chevron color */ + fill: rgba(255, 255, 255, 0.8); /* Chevron color */ } .neuroglancer-accordion-item[data-expanded="false"] @@ -39,7 +39,7 @@ } .neuroglancer-accordion-header-text { - color: rgba(255, 255, 255, 0.80); + color: rgba(255, 255, 255, 0.8); } .neuroglancer-accordion-body { @@ -53,6 +53,7 @@ border-bottom: none; } -.neuroglancer-accordion-item.neuroglancer-accordion-no-border .neuroglancer-accordion-body { +.neuroglancer-accordion-item.neuroglancer-accordion-no-border + .neuroglancer-accordion-body { padding: 0; -} \ No newline at end of file +} diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index b8d152e085..3a6192979b 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -1,6 +1,6 @@ +import svg_chevron_down from "ikonate/icons/chevron-down.svg?raw"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; -import svg_chevron_down from "ikonate/icons/chevron-down.svg?raw"; import { RefCounted } from "#src/util/disposable.js"; import { NullarySignal } from "#src/util/signal.js"; import "#src/widget/accordion.css"; @@ -97,7 +97,10 @@ export class AccordionState extends RefCounted { } toJSON() { - const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; + const useAccordions = + typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; if (!useAccordions) return undefined; const sectionsData = this.sectionStates .map((section) => section.toJSON()) @@ -129,7 +132,10 @@ export class AccordionTab extends Tab { this.defaultKey = options.sections[0].jsonKey; } this.updateSectionsExpanded(); - const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; + const useAccordions = + typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; if (!useAccordions) { this.setAccordionHeadersHidden(true); } @@ -149,13 +155,20 @@ export class AccordionTab extends Tab { String(state.isExpanded.value), ); // Update chevron tooltip - const chevron = section.header.querySelector('.neuroglancer-accordion-chevron'); + const chevron = section.header.querySelector( + ".neuroglancer-accordion-chevron", + ); if (chevron) { - const title = chevron.querySelector('title'); + const title = chevron.querySelector("title"); if (title) { - title.textContent = state.isExpanded.value ? 'Chevron Up' : 'Chevron Down'; + title.textContent = state.isExpanded.value + ? "Chevron Up" + : "Chevron Down"; } - chevron.setAttribute('title', state.isExpanded.value ? 'Chevron Up' : 'Chevron Down'); + chevron.setAttribute( + "title", + state.isExpanded.value ? "Chevron Up" : "Chevron Down", + ); } }); } @@ -183,9 +196,11 @@ export class AccordionTab extends Tab { chevron.classList.add("neuroglancer-accordion-chevron"); chevron.innerHTML = svg_chevron_down; // Set initial tooltip - const initialTooltip = option.defaultExpanded ? 'Chevron Up' : 'Chevron Down'; - chevron.setAttribute('title', initialTooltip); - const title = chevron.querySelector('title'); + const initialTooltip = option.defaultExpanded + ? "Chevron Up" + : "Chevron Down"; + chevron.setAttribute("title", initialTooltip); + const title = chevron.querySelector("title"); if (title) { title.textContent = initialTooltip; } @@ -206,7 +221,10 @@ export class AccordionTab extends Tab { this.setSectionExpanded(option.jsonKey), ); - const useAccordions = typeof NEUROGLANCER_USE_ACCORDIONS !== 'undefined' ? NEUROGLANCER_USE_ACCORDIONS : true; + const useAccordions = + typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; if (!useAccordions) { container.classList.add("neuroglancer-accordion-no-border"); } From 1d42db9a737a8b8959efb72602e2b451a3d90f19 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 11:30:13 +0200 Subject: [PATCH 14/32] refactor: change chevron title logic --- src/widget/accordion.ts | 43 +++++++++++------------------------------ 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 3a6192979b..31a66f677c 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -26,6 +26,7 @@ interface AccordionSection { container: HTMLElement; header: HTMLElement; body: HTMLElement; + chevron: HTMLElement; } export class AccordionSectionState extends RefCounted { @@ -149,27 +150,13 @@ export class AccordionTab extends Tab { this.accordionState.sectionStates.forEach((state) => { const section = this.getSectionByKey(state.jsonKey); if (section === undefined) return; - section.container.dataset.expanded = String(state.isExpanded.value); - section.header.setAttribute( - "aria-expanded", - String(state.isExpanded.value), - ); - // Update chevron tooltip - const chevron = section.header.querySelector( - ".neuroglancer-accordion-chevron", - ); - if (chevron) { - const title = chevron.querySelector("title"); - if (title) { - title.textContent = state.isExpanded.value - ? "Chevron Up" - : "Chevron Down"; - } - chevron.setAttribute( - "title", - state.isExpanded.value ? "Chevron Up" : "Chevron Down", - ); - } + const { container, header, chevron } = section; + container.dataset.expanded = String(state.isExpanded.value); + header.setAttribute("aria-expanded", String(state.isExpanded.value)); + const title = state.isExpanded.value + ? "Collapse accordion section" + : "Expand accordion section"; + chevron.title = title; }); } @@ -182,9 +169,10 @@ export class AccordionTab extends Tab { container: document.createElement("div"), header: document.createElement("div"), body: document.createElement("div"), + chevron: document.createElement("span"), }; this.sections.push(newSection); - const { container, header, body } = newSection; + const { container, header, body, chevron } = newSection; container.classList.add("neuroglancer-accordion-item"); body.classList.add("neuroglancer-accordion-body"); header.classList.add("neuroglancer-accordion-header"); @@ -192,18 +180,9 @@ export class AccordionTab extends Tab { container.appendChild(newSection.body); this.element.appendChild(container); - const chevron = document.createElement("span"); chevron.classList.add("neuroglancer-accordion-chevron"); + chevron.classList.add("neuroglancer-icon"); chevron.innerHTML = svg_chevron_down; - // Set initial tooltip - const initialTooltip = option.defaultExpanded - ? "Chevron Up" - : "Chevron Down"; - chevron.setAttribute("title", initialTooltip); - const title = chevron.querySelector("title"); - if (title) { - title.textContent = initialTooltip; - } const headerText = document.createElement("span"); headerText.classList.add("neuroglancer-accordion-header-text"); headerText.textContent = option.displayName; From 32a237d31d44507a6d0305d2576328f3c1b50c49 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 11:31:33 +0200 Subject: [PATCH 15/32] fix: standardise rspack usage for accordion --- rspack.config.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index 517e4b9717..313b0f0364 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -110,9 +110,7 @@ export default defineConfig((env, args) => { NEUROGLANCER_BRAINMAPS_CLIENT_ID: JSON.stringify( "639403125587-4k5hgdfumtrvur8v48e3pr7oo91d765k.apps.googleusercontent.com", ), - NEUROGLANCER_USE_ACCORDIONS: - process.env.NEUROGLANCER_USE_ACCORDIONS === "true", - + // NEUROGLANCER_USE_ACCORDIONS: false, // NEUROGLANCER_CREDIT_LINK: JSON.stringify({url: '...', text: '...'}), // NEUROGLANCER_DEFAULT_STATE_FRAGMENT: JSON.stringify('gs://bucket/state.json'), // NEUROGLANCER_SHOW_LAYER_BAR_EXTRA_BUTTONS: true, From 8d9b6da90cb7a0bc0da62dffffbc172d70b555cd Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 13:29:55 +0200 Subject: [PATCH 16/32] fix: correct accordion display to flex --- src/ui/annotations.css | 1 - src/widget/accordion.css | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/annotations.css b/src/ui/annotations.css index 39b3b41bfc..8f7e5be263 100644 --- a/src/ui/annotations.css +++ b/src/ui/annotations.css @@ -29,7 +29,6 @@ overflow-y: auto; height: 0px; flex: 1; - flex-basis: 0px; min-height: 0px; } diff --git a/src/widget/accordion.css b/src/widget/accordion.css index 34c2883fdd..428a67ef81 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -4,7 +4,7 @@ .neuroglancer-accordion-item[data-expanded="true"] .neuroglancer-accordion-body { - display: block; /* Show when expanded */ + display: flex; /* Show when expanded */ } .neuroglancer-accordion-item[data-expanded="true"] @@ -47,6 +47,7 @@ overflow-x: auto; overflow-y: auto; padding-top: 0; + flex-direction: column; } .neuroglancer-accordion-item.neuroglancer-accordion-no-border { From 3e866672e52d34f004e8958feb5e2e2a68c754d8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 14:10:49 +0200 Subject: [PATCH 17/32] feat: add default expanded control --- rspack.config.ts | 1 + src/widget/accordion.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rspack.config.ts b/rspack.config.ts index 313b0f0364..118c1e4545 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -111,6 +111,7 @@ export default defineConfig((env, args) => { "639403125587-4k5hgdfumtrvur8v48e3pr7oo91d765k.apps.googleusercontent.com", ), // NEUROGLANCER_USE_ACCORDIONS: false, + // NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED: true, // NEUROGLANCER_CREDIT_LINK: JSON.stringify({url: '...', text: '...'}), // NEUROGLANCER_DEFAULT_STATE_FRAGMENT: JSON.stringify('gs://bucket/state.json'), // NEUROGLANCER_SHOW_LAYER_BAR_EXTRA_BUTTONS: true, diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 31a66f677c..6f5613a1a2 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -7,6 +7,7 @@ import "#src/widget/accordion.css"; import { Tab } from "#src/widget/tab_view.js"; declare let NEUROGLANCER_USE_ACCORDIONS: boolean | undefined; +declare let NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED: boolean | undefined; export interface AccordionOptions { accordionJsonKey: string; @@ -34,7 +35,10 @@ export class AccordionSectionState extends RefCounted { constructor( public jsonKey: string, - private defaultExpanded = false, + private defaultExpanded = typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== + "undefined" + ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED + : false, onChangeCallback: () => void, ) { super(); From 8d886913f352ce54105c97530bdd212297599280 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 14:11:01 +0200 Subject: [PATCH 18/32] fix: add default section to seg render accordion --- src/layer/segmentation/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 7868c46165..6ff2950739 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -646,6 +646,8 @@ export class SegmentationUserLayer extends Base { { jsonKey: APPEARANCE_SECTION_JSON_KEY, displayName: "Appearance", + defaultExpanded: true, + isDefaultKey: true, }, { jsonKey: SLICE_SECTION_JSON_KEY, From 86550850c217868dca9d4c821be51cc87124d34c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 14:23:04 +0200 Subject: [PATCH 19/32] fix: correct skeleton show/hide logic --- src/ui/segmentation_display_options_tab.ts | 4 ++++ src/widget/accordion.css | 14 +++++++++----- src/widget/accordion.ts | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/ui/segmentation_display_options_tab.ts b/src/ui/segmentation_display_options_tab.ts index 94567b3ba3..dce0d46278 100644 --- a/src/ui/segmentation_display_options_tab.ts +++ b/src/ui/segmentation_display_options_tab.ts @@ -132,6 +132,10 @@ export class DisplayOptionsTab extends AccordionTab { SKELETON_SECTION_JSON_KEY, !this.layer.hasSkeletonsLayer.value, ); + this.setSectionHidden( + SKELETON_SECTION_JSON_KEY, + !this.layer.hasSkeletonsLayer.value, + ); this.registerDisposer( this.layer.hasSkeletonsLayer.changed.add(() => { this.setSectionHidden( diff --git a/src/widget/accordion.css b/src/widget/accordion.css index 428a67ef81..ac16906972 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -7,6 +7,15 @@ display: flex; /* Show when expanded */ } +.neuroglancer-accordion-item[data-expanded="false"] + .neuroglancer-accordion-body { + display: none; /* Hide when collapsed */ +} + +.neuroglancer-accordion-item[data-hidden="true"] { + display: none; +} + .neuroglancer-accordion-item[data-expanded="true"] .neuroglancer-accordion-header .neuroglancer-accordion-chevron @@ -25,11 +34,6 @@ fill: rgba(255, 255, 255, 0.8); /* Chevron color */ } -.neuroglancer-accordion-item[data-expanded="false"] - .neuroglancer-accordion-body { - display: none; /* Hide when collapsed */ -} - .neuroglancer-accordion-header { padding: 8px 2px; cursor: pointer; diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 6f5613a1a2..82b6232302 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -254,7 +254,7 @@ export class AccordionTab extends Tab { setSectionHidden(jsonKey: string, hidden: boolean): void { const section = this.getSectionByKey(jsonKey); if (section !== undefined) { - section.container.style.display = hidden ? "none" : ""; + section.container.dataset.hidden = hidden ? "true" : "false"; } } From c20dd00b476025b6d924cab7cea310c69458fd8d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 18:02:05 +0200 Subject: [PATCH 20/32] lint: correct lint errors on example --- python/examples/example_accordion.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/examples/example_accordion.py b/python/examples/example_accordion.py index e947f37bc1..40ea787ff1 100644 --- a/python/examples/example_accordion.py +++ b/python/examples/example_accordion.py @@ -12,7 +12,7 @@ def add_example_layers(state): state.layers.append( name="example_layer", layer=neuroglancer.LocalVolume( - data=np.random.rand(10, 10, 10).astype(np.float32), + data=np.ones((10, 10, 10)).astype(np.float32), dimensions=state.dimensions, ), ) @@ -32,5 +32,3 @@ def add_example_layers(state): s.layers[0].rendering_accordion.slice_expanded = True s.layers[0].rendering_accordion.shader_expanded = False s.layers[0].source_accordion.source_expanded = False - - print(viewer) From f13757cc418177273b9d83ed787272ffd49f37e8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 18:14:35 +0200 Subject: [PATCH 21/32] fix: removed unused source accordion --- python/examples/example_accordion.py | 5 ++++- python/neuroglancer/viewer_state.py | 17 ----------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/python/examples/example_accordion.py b/python/examples/example_accordion.py index 40ea787ff1..fbc152e957 100644 --- a/python/examples/example_accordion.py +++ b/python/examples/example_accordion.py @@ -31,4 +31,7 @@ def add_example_layers(state): s.layers[0].annotations_accordion.related_segments_expanded = True s.layers[0].rendering_accordion.slice_expanded = True s.layers[0].rendering_accordion.shader_expanded = False - s.layers[0].source_accordion.source_expanded = False + s.selected_layer.layer = "example_layer" + s.selected_layer.visible = True + + print(viewer) diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index 84491e5f40..d3834ca525 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -416,20 +416,6 @@ class DimensionPlaybackVelocity(JsonObjectWrapper): paused = wrapped_property("paused", optional(bool, True)) -@export -class SourceAccordion(JsonObjectWrapper): - """Accordion state for layer data source controls.""" - - __slots__ = () - - source_expanded = sourceExpanded = wrapped_property( - "sourceExpanded", optional(bool) - ) - create_expanded = createExpanded = wrapped_property( - "createExpanded", optional(bool) - ) - - @export class AnnotationsAccordion(JsonObjectWrapper): """Accordion state for layer annotation controls.""" @@ -510,9 +496,6 @@ class Layer(JsonObjectWrapper): annotations_accordion = annotationsAccordion = wrapped_property( "annotationsAccordion", AnnotationsAccordion ) - source_accordion = sourceAccordion = wrapped_property( - "sourceAccordion", SourceAccordion - ) @staticmethod def interpolate(a, b, t): From 25baefd2c120677b42b16c4bbbf5c522368ee427 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 2 Apr 2026 18:20:46 +0200 Subject: [PATCH 22/32] chore: small reversions back to master branch --- src/layer/segmentation/layer_controls.ts | 2 +- src/ui/layer_data_sources_tab.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index c6bbc4db93..4e669bb22f 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -1,4 +1,4 @@ -import { type SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; diff --git a/src/ui/layer_data_sources_tab.css b/src/ui/layer_data_sources_tab.css index cea029979f..98bf65737c 100644 --- a/src/ui/layer_data_sources_tab.css +++ b/src/ui/layer_data_sources_tab.css @@ -24,6 +24,7 @@ display: flex; flex-direction: column; flex: 1; + height: 0; z-index: 2; } From 201e92713d42c96c692d3547cc06758d7328fcb0 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Apr 2026 16:53:05 +0200 Subject: [PATCH 23/32] chore: add license notices --- src/widget/accordion.css | 16 ++++++++++++++++ src/widget/accordion.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/widget/accordion.css b/src/widget/accordion.css index ac16906972..8ff0c4062c 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + .neuroglancer-accordion-item { border-bottom: 1px solid #ddd; } diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 82b6232302..dd94225972 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import svg_chevron_down from "ikonate/icons/chevron-down.svg?raw"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; From 49d2b7a0235ad21aeb236958693a1fe60733d354 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Apr 2026 17:00:32 +0200 Subject: [PATCH 24/32] test: add accordion test --- src/widget/accordions.browser_test.ts | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/widget/accordions.browser_test.ts diff --git a/src/widget/accordions.browser_test.ts b/src/widget/accordions.browser_test.ts new file mode 100644 index 0000000000..c40219e9fc --- /dev/null +++ b/src/widget/accordions.browser_test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import { + AccordionState, + AccordionTab, + type AccordionOptions, +} from "#src/widget/accordion.js"; + +function makeAccordionOptions(): AccordionOptions { + return { + accordionJsonKey: "test", + sections: [ + { + jsonKey: "first", + displayName: "First", + defaultExpanded: false, + isDefaultKey: true, + }, + { + jsonKey: "second", + displayName: "Second", + defaultExpanded: true, + }, + ], + }; +} + +describe("accordion", () => { + it("restores and serializes state", () => { + const state = new AccordionState(makeAccordionOptions()); + + expect(state.toJSON()).toBeUndefined(); + + state.restoreState({ first: true, second: false }); + + expect(state.getSectionState("first")?.isExpanded.value).toBe(true); + expect(state.getSectionState("second")?.isExpanded.value).toBe(false); + expect(state.toJSON()).toEqual({ first: true, second: false }); + }); + + it("reflects initial expanded state in the DOM", () => { + const tab = new AccordionTab(new AccordionState(makeAccordionOptions())); + + expect(tab.sections[0].container.dataset.expanded).toBe("false"); + expect(tab.sections[0].header.getAttribute("aria-expanded")).toBe("false"); + expect(tab.sections[1].container.dataset.expanded).toBe("true"); + expect(tab.sections[1].header.getAttribute("aria-expanded")).toBe("true"); + }); + + it("toggles expanded state when header is clicked", () => { + const state = new AccordionState(makeAccordionOptions()); + const tab = new AccordionTab(state); + + const section = tab.sections[0]; + expect(section.container.dataset.expanded).toBe("false"); + + section.header.click(); + + expect(state.getSectionState("first")?.isExpanded.value).toBe(true); + expect(section.container.dataset.expanded).toBe("true"); + expect(section.header.getAttribute("aria-expanded")).toBe("true"); + expect(section.chevron.title).toBe("Collapse accordion section"); + }); + + it("appends content to the requested section and shows it", () => { + const tab = new AccordionTab(new AccordionState(makeAccordionOptions())); + const child = document.createElement("div"); + child.textContent = "hello"; + + tab.appendChild(child, "second"); + + expect(tab.sections[1].body.contains(child)).toBe(true); + expect(tab.sections[1].container.style.display).toBe(""); + + // With no section specified, appends to default section + tab.appendChild(child); + expect(tab.sections[0].body.contains(child)).toBe(true); + }); +}); From 9132a2d3c5d1e928586aa47b492b1c76cc60de2a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Apr 2026 17:25:17 +0200 Subject: [PATCH 25/32] chore: correct naming --- .../{accordions.browser_test.ts => accordion.browser_test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/widget/{accordions.browser_test.ts => accordion.browser_test.ts} (100%) diff --git a/src/widget/accordions.browser_test.ts b/src/widget/accordion.browser_test.ts similarity index 100% rename from src/widget/accordions.browser_test.ts rename to src/widget/accordion.browser_test.ts From 9c72b28923ed25fba30b7c696f95c5cdf68c6d79 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Apr 2026 17:35:23 +0200 Subject: [PATCH 26/32] refactor: more explicitly handle mix of global and local state --- src/widget/accordion.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index dd94225972..a4958d7118 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -51,10 +51,7 @@ export class AccordionSectionState extends RefCounted { constructor( public jsonKey: string, - private defaultExpanded = typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== - "undefined" - ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED - : false, + private defaultExpanded: boolean, onChangeCallback: () => void, ) { super(); @@ -81,12 +78,16 @@ export class AccordionState extends RefCounted { getOrCreateSectionState(sectionOptions: AccordionSectionOptions) { const { jsonKey, defaultExpanded } = sectionOptions; + const globalDefaultExpanded = + typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== "undefined" + ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED + : false; let sectionState = this.getSectionState(jsonKey); if (sectionState === undefined) { sectionState = this.registerDisposer( new AccordionSectionState( jsonKey, - defaultExpanded, + defaultExpanded ?? globalDefaultExpanded, this.specificationChanged.dispatch, ), ); From 377d72b23ebf4c460d278e3640046af136a32225 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Apr 2026 18:35:23 +0200 Subject: [PATCH 27/32] refactor: clarify let handling --- src/widget/accordion.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index a4958d7118..b32546c498 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -46,6 +46,18 @@ interface AccordionSection { chevron: HTMLElement; } +function getGlobalAccordionDefaultExpanded(): boolean { + return typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== "undefined" + ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED + : false; +} + +function getGlobalUseAccordions(): boolean { + return typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; +} + export class AccordionSectionState extends RefCounted { isExpanded: WatchableValueInterface; @@ -78,16 +90,12 @@ export class AccordionState extends RefCounted { getOrCreateSectionState(sectionOptions: AccordionSectionOptions) { const { jsonKey, defaultExpanded } = sectionOptions; - const globalDefaultExpanded = - typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== "undefined" - ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED - : false; let sectionState = this.getSectionState(jsonKey); if (sectionState === undefined) { sectionState = this.registerDisposer( new AccordionSectionState( jsonKey, - defaultExpanded ?? globalDefaultExpanded, + defaultExpanded ?? getGlobalAccordionDefaultExpanded(), this.specificationChanged.dispatch, ), ); @@ -112,18 +120,12 @@ export class AccordionState extends RefCounted { return; } for (const [jsonKey, isExpanded] of Object.entries(obj)) { - if (typeof isExpanded === "boolean") { - this.setSectionExpanded(jsonKey, isExpanded); - } + this.setSectionExpanded(jsonKey, isExpanded); } } toJSON() { - const useAccordions = - typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" - ? NEUROGLANCER_USE_ACCORDIONS - : true; - if (!useAccordions) return undefined; + if (!getGlobalUseAccordions()) return undefined; const sectionsData = this.sectionStates .map((section) => section.toJSON()) .filter((data) => data !== undefined); From 0c0c67e285e49a98fbcc1bb945855b7c71e143c9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 8 Apr 2026 12:37:06 +0200 Subject: [PATCH 28/32] fix: disabling accordions does not modify state --- src/widget/accordion.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index b32546c498..9d6e670181 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -120,12 +120,12 @@ export class AccordionState extends RefCounted { return; } for (const [jsonKey, isExpanded] of Object.entries(obj)) { + if (typeof isExpanded !== "boolean") continue; this.setSectionExpanded(jsonKey, isExpanded); } } toJSON() { - if (!getGlobalUseAccordions()) return undefined; const sectionsData = this.sectionStates .map((section) => section.toJSON()) .filter((data) => data !== undefined); @@ -156,11 +156,7 @@ export class AccordionTab extends Tab { this.defaultKey = options.sections[0].jsonKey; } this.updateSectionsExpanded(); - const useAccordions = - typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" - ? NEUROGLANCER_USE_ACCORDIONS - : true; - if (!useAccordions) { + if (!getGlobalUseAccordions()) { this.setAccordionHeadersHidden(true); } } @@ -170,16 +166,17 @@ export class AccordionTab extends Tab { } private updateSectionsExpanded() { + const accordionsDisabled = !getGlobalUseAccordions(); this.accordionState.sectionStates.forEach((state) => { const section = this.getSectionByKey(state.jsonKey); if (section === undefined) return; const { container, header, chevron } = section; - container.dataset.expanded = String(state.isExpanded.value); - header.setAttribute("aria-expanded", String(state.isExpanded.value)); - const title = state.isExpanded.value + const expand = accordionsDisabled || state.isExpanded.value; + container.dataset.expanded = String(expand); + header.setAttribute("aria-expanded", String(expand)); + chevron.title = expand ? "Collapse accordion section" : "Expand accordion section"; - chevron.title = title; }); } @@ -212,7 +209,6 @@ export class AccordionTab extends Tab { header.appendChild(headerText); header.appendChild(chevron); - container.style.display = "none"; container.dataset.expanded = String(option.defaultExpanded ?? false); if (option.isDefaultKey) { @@ -296,9 +292,6 @@ export class AccordionTab extends Tab { setAccordionHeadersHidden(hidden: boolean): void { this.sections.forEach((section) => { section.header.style.display = hidden ? "none" : ""; - if (hidden) { - this.setSectionExpanded(section.jsonKey, true); - } }); } } From e7d6adf07f6cdc43609c56a8f4c03f67ee798ca9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 8 Apr 2026 12:38:14 +0200 Subject: [PATCH 29/32] fix: dispose accordion state in seg layer --- src/layer/segmentation/index.ts | 56 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 056f2a73aa..e6ea6a6f9b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -636,33 +636,35 @@ export class SegmentationUserLayer extends Base { x === undefined ? undefined : parseUint64(x), ); - renderingAccordionState = new AccordionState({ - accordionJsonKey: SEGMENTATION_RENDERING_ACCORDION_JSON_KEY, - sections: [ - { - jsonKey: VISIBILITY_SECTION_JSON_KEY, - displayName: "Visibility", - }, - { - jsonKey: APPEARANCE_SECTION_JSON_KEY, - displayName: "Appearance", - defaultExpanded: true, - isDefaultKey: true, - }, - { - jsonKey: SLICE_SECTION_JSON_KEY, - displayName: "Slice 2D", - }, - { - jsonKey: MESH_SECTION_JSON_KEY, - displayName: "Mesh 3D", - }, - { - jsonKey: SKELETON_SECTION_JSON_KEY, - displayName: "Skeletons", - }, - ], - }); + renderingAccordionState = this.registerDisposer( + new AccordionState({ + accordionJsonKey: SEGMENTATION_RENDERING_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: VISIBILITY_SECTION_JSON_KEY, + displayName: "Visibility", + }, + { + jsonKey: APPEARANCE_SECTION_JSON_KEY, + displayName: "Appearance", + defaultExpanded: true, + isDefaultKey: true, + }, + { + jsonKey: SLICE_SECTION_JSON_KEY, + displayName: "Slice 2D", + }, + { + jsonKey: MESH_SECTION_JSON_KEY, + displayName: "Mesh 3D", + }, + { + jsonKey: SKELETON_SECTION_JSON_KEY, + displayName: "Skeletons", + }, + ], + }), + ); constructor(managedLayer: Borrowed) { super(managedLayer); From 33e3f3b95f628f23adeb92a97b017a9155e480e4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 15 May 2026 15:02:16 +0200 Subject: [PATCH 30/32] fix: correct hidden section behaviour --- src/widget/accordion.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 9d6e670181..649117fc49 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -210,6 +210,9 @@ export class AccordionTab extends Tab { header.appendChild(chevron); container.dataset.expanded = String(option.defaultExpanded ?? false); + // Adding a child element automatically sets the hidden attribute to false + // so this hides empty sections + container.dataset.hidden = "true"; if (option.isDefaultKey) { this.defaultKey = option.jsonKey; @@ -256,10 +259,13 @@ export class AccordionTab extends Tab { return section; } + // Hidden can be used here to keep the section hidden on adding a child + // but the usual behaviour is to show the section after adding a child + // because then we know the section is not empty appendChild(content: HTMLElement, jsonKey?: string, hidden?: boolean): void { const section = this.getSectionWithFallback(jsonKey); section.body.appendChild(content); - if (!hidden) section.container.style.display = ""; + section.container.dataset.hidden = hidden ? "true" : "false"; } /** From 8ccfbd6fe9302cabf5ea06591224243cf8d3fbf9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 15 May 2026 15:14:53 +0200 Subject: [PATCH 31/32] fix: make scroll bars not be per section --- src/widget/accordion.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widget/accordion.css b/src/widget/accordion.css index 8ff0c4062c..b2b29ceda4 100644 --- a/src/widget/accordion.css +++ b/src/widget/accordion.css @@ -64,8 +64,8 @@ .neuroglancer-accordion-body { padding: 8px 2px; - overflow-x: auto; - overflow-y: auto; + overflow-x: visible; + overflow-y: visible; padding-top: 0; flex-direction: column; } From 3490aa104b4d852ca3b97bbe5079e6716b84e8fa Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 15 May 2026 15:26:08 +0200 Subject: [PATCH 32/32] fix: correct annotation spacing show/hide logic --- src/layer/annotation/index.ts | 2 +- src/widget/accordion.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 02f5852b98..6591ca39cb 100644 --- a/src/layer/annotation/index.ts +++ b/src/layer/annotation/index.ts @@ -714,7 +714,7 @@ export class AnnotationUserLayer extends Base { tab.appendChild( renderScaleControls.element, SPACING_SECTION_JSON_KEY, - true /* hidden */, + !hasChunkedSource.value, ); { const checkbox = tab.registerDisposer( diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts index 649117fc49..21917607ee 100644 --- a/src/widget/accordion.ts +++ b/src/widget/accordion.ts @@ -259,13 +259,18 @@ export class AccordionTab extends Tab { return section; } - // Hidden can be used here to keep the section hidden on adding a child - // but the usual behaviour is to show the section after adding a child - // because then we know the section is not empty - appendChild(content: HTMLElement, jsonKey?: string, hidden?: boolean): void { + // Usually adding a child automatically shows the section + // but skipShow can be used to avoid this behaviour + appendChild( + content: HTMLElement, + jsonKey?: string, + skipShow?: boolean, + ): void { const section = this.getSectionWithFallback(jsonKey); section.body.appendChild(content); - section.container.dataset.hidden = hidden ? "true" : "false"; + if (!skipShow) { + this.showSection(section.jsonKey); + } } /**