From 8b74a5c748492303b54a4586cfcf5df8496e8b3c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 2 Sep 2025 18:00:13 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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; }