diff --git a/.gitignore b/.gitignore index 950f261c0..e6316728d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ dmypy.json # diffs from visual regression tests examples/desktop/diffs/*.png docs/source/_gallery/ +docs/source/_imgui_images/ diff --git a/docs/source/api/graphic_features/ImageGamma.rst b/docs/source/api/graphic_features/ImageGamma.rst new file mode 100644 index 000000000..d49347e87 --- /dev/null +++ b/docs/source/api/graphic_features/ImageGamma.rst @@ -0,0 +1,35 @@ +.. _api.ImageGamma: + +ImageGamma +********** + +========== +ImageGamma +========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma.add_event_handler + ImageGamma.block_events + ImageGamma.clear_event_handlers + ImageGamma.remove_event_handler + ImageGamma.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index db0b52103..b73f4f17c 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -23,8 +23,8 @@ Graphic Features UniformSize TextureArray TextureYUV - tuple ImageCmap + ImageGamma ImageVmin ImageVmax ImageInterpolation diff --git a/docs/source/api/graphic_features/tuple.rst b/docs/source/api/graphic_features/tuple.rst deleted file mode 100644 index 2c0c9c662..000000000 --- a/docs/source/api/graphic_features/tuple.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _api.tuple: - -tuple -***** - -===== -tuple -===== -.. currentmodule:: fastplotlib.graphics.features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: tuple_api - - tuple - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: tuple_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: tuple_api - - tuple.count - tuple.index - diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index b2bf0ddd0..c6f393507 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -27,9 +27,9 @@ Properties Graphic.block_handlers Graphic.deleted Graphic.event_handlers + Graphic.imgui_right_click Graphic.name Graphic.offset - Graphic.right_click_menu Graphic.rotation Graphic.scale Graphic.supported_events @@ -44,10 +44,13 @@ Methods Graphic.add_axes Graphic.add_event_handler + Graphic.append_imgui_right_click Graphic.clear_event_handlers Graphic.format_pick_info Graphic.map_model_to_world Graphic.map_world_to_model Graphic.remove_event_handler + Graphic.remove_imgui_right_click Graphic.rotate + Graphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index b95b47907..6190343b8 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -32,10 +32,11 @@ Properties ImageGraphic.data ImageGraphic.deleted ImageGraphic.event_handlers + ImageGraphic.gamma + ImageGraphic.imgui_right_click ImageGraphic.interpolation ImageGraphic.name ImageGraphic.offset - ImageGraphic.right_click_menu ImageGraphic.rotation ImageGraphic.scale ImageGraphic.supported_events @@ -56,11 +57,14 @@ Methods ImageGraphic.add_linear_selector ImageGraphic.add_polygon_selector ImageGraphic.add_rectangle_selector + ImageGraphic.append_imgui_right_click ImageGraphic.clear_event_handlers ImageGraphic.format_pick_info ImageGraphic.map_model_to_world ImageGraphic.map_world_to_model ImageGraphic.remove_event_handler + ImageGraphic.remove_imgui_right_click ImageGraphic.reset_vmin_vmax ImageGraphic.rotate + ImageGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst index c0465944d..b1f8a8dfb 100644 --- a/docs/source/api/graphics/ImageVolumeGraphic.rst +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -31,12 +31,13 @@ Properties ImageVolumeGraphic.deleted ImageVolumeGraphic.emissive ImageVolumeGraphic.event_handlers + ImageVolumeGraphic.gamma + ImageVolumeGraphic.imgui_right_click ImageVolumeGraphic.interpolation ImageVolumeGraphic.mode ImageVolumeGraphic.name ImageVolumeGraphic.offset ImageVolumeGraphic.plane - ImageVolumeGraphic.right_click_menu ImageVolumeGraphic.rotation ImageVolumeGraphic.scale ImageVolumeGraphic.shininess @@ -57,11 +58,14 @@ Methods ImageVolumeGraphic.add_axes ImageVolumeGraphic.add_event_handler + ImageVolumeGraphic.append_imgui_right_click ImageVolumeGraphic.clear_event_handlers ImageVolumeGraphic.format_pick_info ImageVolumeGraphic.map_model_to_world ImageVolumeGraphic.map_world_to_model ImageVolumeGraphic.remove_event_handler + ImageVolumeGraphic.remove_imgui_right_click ImageVolumeGraphic.reset_vmin_vmax ImageVolumeGraphic.rotate + ImageVolumeGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageYUVGraphic.rst b/docs/source/api/graphics/ImageYUVGraphic.rst index 54c7c3c1a..6db01387c 100644 --- a/docs/source/api/graphics/ImageYUVGraphic.rst +++ b/docs/source/api/graphics/ImageYUVGraphic.rst @@ -33,10 +33,11 @@ Properties ImageYUVGraphic.data ImageYUVGraphic.deleted ImageYUVGraphic.event_handlers + ImageYUVGraphic.gamma + ImageYUVGraphic.imgui_right_click ImageYUVGraphic.interpolation ImageYUVGraphic.name ImageYUVGraphic.offset - ImageYUVGraphic.right_click_menu ImageYUVGraphic.rotation ImageYUVGraphic.scale ImageYUVGraphic.supported_events @@ -57,11 +58,14 @@ Methods ImageYUVGraphic.add_linear_selector ImageYUVGraphic.add_polygon_selector ImageYUVGraphic.add_rectangle_selector + ImageYUVGraphic.append_imgui_right_click ImageYUVGraphic.clear_event_handlers ImageYUVGraphic.format_pick_info ImageYUVGraphic.map_model_to_world ImageYUVGraphic.map_world_to_model ImageYUVGraphic.remove_event_handler + ImageYUVGraphic.remove_imgui_right_click ImageYUVGraphic.reset_vmin_vmax ImageYUVGraphic.rotate + ImageYUVGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index de0a8330c..c9f145d38 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -31,12 +31,12 @@ Properties LineCollection.deleted LineCollection.event_handlers LineCollection.graphics + LineCollection.imgui_right_click LineCollection.metadatas LineCollection.name LineCollection.names LineCollection.offset LineCollection.offsets - LineCollection.right_click_menu LineCollection.rotation LineCollection.rotations LineCollection.scale @@ -59,11 +59,14 @@ Methods LineCollection.add_linear_selector LineCollection.add_polygon_selector LineCollection.add_rectangle_selector + LineCollection.append_imgui_right_click LineCollection.clear_event_handlers LineCollection.format_pick_info LineCollection.map_model_to_world LineCollection.map_world_to_model LineCollection.remove_event_handler LineCollection.remove_graphic + LineCollection.remove_imgui_right_click LineCollection.rotate + LineCollection.set_imgui_right_click diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 834bce0a9..4faf77c5c 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -31,9 +31,9 @@ Properties LineGraphic.data LineGraphic.deleted LineGraphic.event_handlers + LineGraphic.imgui_right_click LineGraphic.name LineGraphic.offset - LineGraphic.right_click_menu LineGraphic.rotation LineGraphic.scale LineGraphic.size_space @@ -54,10 +54,13 @@ Methods LineGraphic.add_linear_selector LineGraphic.add_polygon_selector LineGraphic.add_rectangle_selector + LineGraphic.append_imgui_right_click LineGraphic.clear_event_handlers LineGraphic.format_pick_info LineGraphic.map_model_to_world LineGraphic.map_world_to_model LineGraphic.remove_event_handler + LineGraphic.remove_imgui_right_click LineGraphic.rotate + LineGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index a922b9edc..f2a3f9958 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -31,12 +31,12 @@ Properties LineStack.deleted LineStack.event_handlers LineStack.graphics + LineStack.imgui_right_click LineStack.metadatas LineStack.name LineStack.names LineStack.offset LineStack.offsets - LineStack.right_click_menu LineStack.rotation LineStack.rotations LineStack.scale @@ -59,11 +59,14 @@ Methods LineStack.add_linear_selector LineStack.add_polygon_selector LineStack.add_rectangle_selector + LineStack.append_imgui_right_click LineStack.clear_event_handlers LineStack.format_pick_info LineStack.map_model_to_world LineStack.map_world_to_model LineStack.remove_event_handler LineStack.remove_graphic + LineStack.remove_imgui_right_click LineStack.rotate + LineStack.set_imgui_right_click diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst index c2cf895e1..4ed70bf37 100644 --- a/docs/source/api/graphics/MeshGraphic.rst +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -30,6 +30,7 @@ Properties MeshGraphic.colors MeshGraphic.deleted MeshGraphic.event_handlers + MeshGraphic.imgui_right_click MeshGraphic.indices MeshGraphic.mapcoords MeshGraphic.mode @@ -37,7 +38,6 @@ Properties MeshGraphic.offset MeshGraphic.plane MeshGraphic.positions - MeshGraphic.right_click_menu MeshGraphic.rotation MeshGraphic.scale MeshGraphic.supported_events @@ -52,10 +52,13 @@ Methods MeshGraphic.add_axes MeshGraphic.add_event_handler + MeshGraphic.append_imgui_right_click MeshGraphic.clear_event_handlers MeshGraphic.format_pick_info MeshGraphic.map_model_to_world MeshGraphic.map_world_to_model MeshGraphic.remove_event_handler + MeshGraphic.remove_imgui_right_click MeshGraphic.rotate + MeshGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst index c52031d67..9045a3e10 100644 --- a/docs/source/api/graphics/PolygonGraphic.rst +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -31,6 +31,7 @@ Properties PolygonGraphic.data PolygonGraphic.deleted PolygonGraphic.event_handlers + PolygonGraphic.imgui_right_click PolygonGraphic.indices PolygonGraphic.mapcoords PolygonGraphic.mode @@ -38,7 +39,6 @@ Properties PolygonGraphic.offset PolygonGraphic.plane PolygonGraphic.positions - PolygonGraphic.right_click_menu PolygonGraphic.rotation PolygonGraphic.scale PolygonGraphic.supported_events @@ -53,10 +53,13 @@ Methods PolygonGraphic.add_axes PolygonGraphic.add_event_handler + PolygonGraphic.append_imgui_right_click PolygonGraphic.clear_event_handlers PolygonGraphic.format_pick_info PolygonGraphic.map_model_to_world PolygonGraphic.map_world_to_model PolygonGraphic.remove_event_handler + PolygonGraphic.remove_imgui_right_click PolygonGraphic.rotate + PolygonGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterCollection.rst b/docs/source/api/graphics/ScatterCollection.rst index 92fa92a78..f71116948 100644 --- a/docs/source/api/graphics/ScatterCollection.rst +++ b/docs/source/api/graphics/ScatterCollection.rst @@ -31,13 +31,13 @@ Properties ScatterCollection.deleted ScatterCollection.event_handlers ScatterCollection.graphics + ScatterCollection.imgui_right_click ScatterCollection.markers ScatterCollection.metadatas ScatterCollection.name ScatterCollection.names ScatterCollection.offset ScatterCollection.offsets - ScatterCollection.right_click_menu ScatterCollection.rotation ScatterCollection.rotations ScatterCollection.scale @@ -60,11 +60,14 @@ Methods ScatterCollection.add_linear_selector ScatterCollection.add_polygon_selector ScatterCollection.add_rectangle_selector + ScatterCollection.append_imgui_right_click ScatterCollection.clear_event_handlers ScatterCollection.format_pick_info ScatterCollection.map_model_to_world ScatterCollection.map_world_to_model ScatterCollection.remove_event_handler ScatterCollection.remove_graphic + ScatterCollection.remove_imgui_right_click ScatterCollection.rotate + ScatterCollection.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 0406fa8cc..c9f988820 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -34,13 +34,13 @@ Properties ScatterGraphic.edge_width ScatterGraphic.event_handlers ScatterGraphic.image + ScatterGraphic.imgui_right_click ScatterGraphic.markers ScatterGraphic.mode ScatterGraphic.name ScatterGraphic.offset ScatterGraphic.point_rotation_mode ScatterGraphic.point_rotations - ScatterGraphic.right_click_menu ScatterGraphic.rotation ScatterGraphic.scale ScatterGraphic.size_space @@ -57,10 +57,13 @@ Methods ScatterGraphic.add_axes ScatterGraphic.add_event_handler + ScatterGraphic.append_imgui_right_click ScatterGraphic.clear_event_handlers ScatterGraphic.format_pick_info ScatterGraphic.map_model_to_world ScatterGraphic.map_world_to_model ScatterGraphic.remove_event_handler + ScatterGraphic.remove_imgui_right_click ScatterGraphic.rotate + ScatterGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterStack.rst b/docs/source/api/graphics/ScatterStack.rst index 22aaa4d5d..ee0d7d679 100644 --- a/docs/source/api/graphics/ScatterStack.rst +++ b/docs/source/api/graphics/ScatterStack.rst @@ -31,13 +31,13 @@ Properties ScatterStack.deleted ScatterStack.event_handlers ScatterStack.graphics + ScatterStack.imgui_right_click ScatterStack.markers ScatterStack.metadatas ScatterStack.name ScatterStack.names ScatterStack.offset ScatterStack.offsets - ScatterStack.right_click_menu ScatterStack.rotation ScatterStack.rotations ScatterStack.scale @@ -62,11 +62,14 @@ Methods ScatterStack.add_linear_selector ScatterStack.add_polygon_selector ScatterStack.add_rectangle_selector + ScatterStack.append_imgui_right_click ScatterStack.clear_event_handlers ScatterStack.format_pick_info ScatterStack.map_model_to_world ScatterStack.map_world_to_model ScatterStack.remove_event_handler ScatterStack.remove_graphic + ScatterStack.remove_imgui_right_click ScatterStack.rotate + ScatterStack.set_imgui_right_click diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst index 2eb32500b..a1088fa81 100644 --- a/docs/source/api/graphics/SurfaceGraphic.rst +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -31,6 +31,7 @@ Properties SurfaceGraphic.data SurfaceGraphic.deleted SurfaceGraphic.event_handlers + SurfaceGraphic.imgui_right_click SurfaceGraphic.indices SurfaceGraphic.mapcoords SurfaceGraphic.mode @@ -38,7 +39,6 @@ Properties SurfaceGraphic.offset SurfaceGraphic.plane SurfaceGraphic.positions - SurfaceGraphic.right_click_menu SurfaceGraphic.rotation SurfaceGraphic.scale SurfaceGraphic.supported_events @@ -53,10 +53,13 @@ Methods SurfaceGraphic.add_axes SurfaceGraphic.add_event_handler + SurfaceGraphic.append_imgui_right_click SurfaceGraphic.clear_event_handlers SurfaceGraphic.format_pick_info SurfaceGraphic.map_model_to_world SurfaceGraphic.map_world_to_model SurfaceGraphic.remove_event_handler + SurfaceGraphic.remove_imgui_right_click SurfaceGraphic.rotate + SurfaceGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index e4deb0113..2260306a7 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -29,11 +29,11 @@ Properties TextGraphic.event_handlers TextGraphic.face_color TextGraphic.font_size + TextGraphic.imgui_right_click TextGraphic.name TextGraphic.offset TextGraphic.outline_color TextGraphic.outline_thickness - TextGraphic.right_click_menu TextGraphic.rotation TextGraphic.scale TextGraphic.supported_events @@ -49,10 +49,13 @@ Methods TextGraphic.add_axes TextGraphic.add_event_handler + TextGraphic.append_imgui_right_click TextGraphic.clear_event_handlers TextGraphic.format_pick_info TextGraphic.map_model_to_world TextGraphic.map_world_to_model TextGraphic.remove_event_handler + TextGraphic.remove_imgui_right_click TextGraphic.rotate + TextGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst index 728029851..353e42ada 100644 --- a/docs/source/api/graphics/VectorsGraphic.rst +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -28,10 +28,10 @@ Properties VectorsGraphic.deleted VectorsGraphic.directions VectorsGraphic.event_handlers + VectorsGraphic.imgui_right_click VectorsGraphic.name VectorsGraphic.offset VectorsGraphic.positions - VectorsGraphic.right_click_menu VectorsGraphic.rotation VectorsGraphic.scale VectorsGraphic.supported_events @@ -46,10 +46,13 @@ Methods VectorsGraphic.add_axes VectorsGraphic.add_event_handler + VectorsGraphic.append_imgui_right_click VectorsGraphic.clear_event_handlers VectorsGraphic.format_pick_info VectorsGraphic.map_model_to_world VectorsGraphic.map_world_to_model VectorsGraphic.remove_event_handler + VectorsGraphic.remove_imgui_right_click VectorsGraphic.rotate + VectorsGraphic.set_imgui_right_click diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index 54e91b24f..ee7f16eb0 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -42,7 +42,6 @@ Methods Figure.export Figure.export_numpy Figure.get_pygfx_render_area - Figure.open_popup Figure.remove_animation Figure.remove_subplot Figure.show diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index fc3471afc..ace763861 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -25,13 +25,13 @@ Properties ImguiFigure.canvas ImguiFigure.controllers ImguiFigure.default_imgui_font - ImguiFigure.guis ImguiFigure.imgui_renderer + ImguiFigure.imgui_right_click + ImguiFigure.imgui_windows ImguiFigure.layout ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape - ImguiFigure.std_right_click_menu Methods ~~~~~~~ @@ -39,17 +39,20 @@ Methods :toctree: ImguiFigure_api ImguiFigure.add_animations - ImguiFigure.add_gui + ImguiFigure.add_imgui_window ImguiFigure.add_subplot + ImguiFigure.append_imgui_right_click + ImguiFigure.append_imgui_window ImguiFigure.clear ImguiFigure.clear_animations ImguiFigure.close ImguiFigure.export ImguiFigure.export_numpy ImguiFigure.get_pygfx_render_area - ImguiFigure.open_popup - ImguiFigure.register_popup ImguiFigure.remove_animation + ImguiFigure.remove_imgui_right_click + ImguiFigure.remove_imgui_window ImguiFigure.remove_subplot + ImguiFigure.set_imgui_right_click ImguiFigure.show diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 994a252fd..09bd14e39 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -31,6 +31,8 @@ Properties Subplot.docks Subplot.frame Subplot.graphics + Subplot.imgui_right_click + Subplot.imgui_windows Subplot.legends Subplot.name Subplot.objects @@ -55,6 +57,7 @@ Methods Subplot.add_image Subplot.add_image_volume Subplot.add_image_yuv + Subplot.add_imgui_window Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack @@ -66,6 +69,8 @@ Methods Subplot.add_surface Subplot.add_text Subplot.add_vectors + Subplot.append_imgui_right_click + Subplot.append_imgui_window Subplot.auto_scale Subplot.center_graphic Subplot.center_scene @@ -79,4 +84,7 @@ Methods Subplot.map_world_to_screen Subplot.remove_animation Subplot.remove_graphic + Subplot.remove_imgui_right_click + Subplot.remove_imgui_window + Subplot.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index 07baa200f..2b781a886 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -30,11 +30,11 @@ Properties LinearRegionSelector.edge_color LinearRegionSelector.event_handlers LinearRegionSelector.fill_color + LinearRegionSelector.imgui_right_click LinearRegionSelector.limits LinearRegionSelector.name LinearRegionSelector.offset LinearRegionSelector.parent - LinearRegionSelector.right_click_menu LinearRegionSelector.rotation LinearRegionSelector.scale LinearRegionSelector.selection @@ -51,6 +51,7 @@ Methods LinearRegionSelector.add_axes LinearRegionSelector.add_event_handler + LinearRegionSelector.append_imgui_right_click LinearRegionSelector.clear_event_handlers LinearRegionSelector.format_pick_info LinearRegionSelector.get_selected_data @@ -59,5 +60,7 @@ Methods LinearRegionSelector.map_model_to_world LinearRegionSelector.map_world_to_model LinearRegionSelector.remove_event_handler + LinearRegionSelector.remove_imgui_right_click LinearRegionSelector.rotate + LinearRegionSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearRegionSelectors.rst b/docs/source/api/selectors/LinearRegionSelectors.rst index 64e5675d4..867e43b9f 100644 --- a/docs/source/api/selectors/LinearRegionSelectors.rst +++ b/docs/source/api/selectors/LinearRegionSelectors.rst @@ -27,9 +27,9 @@ Properties LinearRegionSelectors.block_handlers LinearRegionSelectors.deleted LinearRegionSelectors.event_handlers + LinearRegionSelectors.imgui_right_click LinearRegionSelectors.name LinearRegionSelectors.offset - LinearRegionSelectors.right_click_menu LinearRegionSelectors.rotation LinearRegionSelectors.scale LinearRegionSelectors.selection @@ -46,6 +46,7 @@ Methods LinearRegionSelectors.add_axes LinearRegionSelectors.add_event_handler LinearRegionSelectors.append + LinearRegionSelectors.append_imgui_right_click LinearRegionSelectors.clear LinearRegionSelectors.clear_event_handlers LinearRegionSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods LinearRegionSelectors.map_world_to_model LinearRegionSelectors.remove LinearRegionSelectors.remove_event_handler + LinearRegionSelectors.remove_imgui_right_click LinearRegionSelectors.rotate + LinearRegionSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index e0e98bc13..eef5a5175 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -30,11 +30,11 @@ Properties LinearSelector.edge_color LinearSelector.event_handlers LinearSelector.fill_color + LinearSelector.imgui_right_click LinearSelector.limits LinearSelector.name LinearSelector.offset LinearSelector.parent - LinearSelector.right_click_menu LinearSelector.rotation LinearSelector.scale LinearSelector.selection @@ -51,6 +51,7 @@ Methods LinearSelector.add_axes LinearSelector.add_event_handler + LinearSelector.append_imgui_right_click LinearSelector.clear_event_handlers LinearSelector.format_pick_info LinearSelector.get_selected_data @@ -59,5 +60,7 @@ Methods LinearSelector.map_model_to_world LinearSelector.map_world_to_model LinearSelector.remove_event_handler + LinearSelector.remove_imgui_right_click LinearSelector.rotate + LinearSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearSelectors.rst b/docs/source/api/selectors/LinearSelectors.rst index 87204d070..f01de0e7c 100644 --- a/docs/source/api/selectors/LinearSelectors.rst +++ b/docs/source/api/selectors/LinearSelectors.rst @@ -27,9 +27,9 @@ Properties LinearSelectors.block_handlers LinearSelectors.deleted LinearSelectors.event_handlers + LinearSelectors.imgui_right_click LinearSelectors.name LinearSelectors.offset - LinearSelectors.right_click_menu LinearSelectors.rotation LinearSelectors.scale LinearSelectors.selection @@ -46,6 +46,7 @@ Methods LinearSelectors.add_axes LinearSelectors.add_event_handler LinearSelectors.append + LinearSelectors.append_imgui_right_click LinearSelectors.clear LinearSelectors.clear_event_handlers LinearSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods LinearSelectors.map_world_to_model LinearSelectors.remove LinearSelectors.remove_event_handler + LinearSelectors.remove_imgui_right_click LinearSelectors.rotate + LinearSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/PolygonSelectors.rst b/docs/source/api/selectors/PolygonSelectors.rst index b670e8bfd..f0855e78a 100644 --- a/docs/source/api/selectors/PolygonSelectors.rst +++ b/docs/source/api/selectors/PolygonSelectors.rst @@ -27,9 +27,9 @@ Properties PolygonSelectors.block_handlers PolygonSelectors.deleted PolygonSelectors.event_handlers + PolygonSelectors.imgui_right_click PolygonSelectors.name PolygonSelectors.offset - PolygonSelectors.right_click_menu PolygonSelectors.rotation PolygonSelectors.scale PolygonSelectors.selection @@ -46,6 +46,7 @@ Methods PolygonSelectors.add_axes PolygonSelectors.add_event_handler PolygonSelectors.append + PolygonSelectors.append_imgui_right_click PolygonSelectors.clear PolygonSelectors.clear_event_handlers PolygonSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods PolygonSelectors.map_world_to_model PolygonSelectors.remove PolygonSelectors.remove_event_handler + PolygonSelectors.remove_imgui_right_click PolygonSelectors.rotate + PolygonSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index a9a8d9fd5..bf75fa7e2 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -30,11 +30,11 @@ Properties RectangleSelector.edge_color RectangleSelector.event_handlers RectangleSelector.fill_color + RectangleSelector.imgui_right_click RectangleSelector.limits RectangleSelector.name RectangleSelector.offset RectangleSelector.parent - RectangleSelector.right_click_menu RectangleSelector.rotation RectangleSelector.scale RectangleSelector.selection @@ -51,6 +51,7 @@ Methods RectangleSelector.add_axes RectangleSelector.add_event_handler + RectangleSelector.append_imgui_right_click RectangleSelector.clear_event_handlers RectangleSelector.format_pick_info RectangleSelector.get_selected_data @@ -59,5 +60,7 @@ Methods RectangleSelector.map_model_to_world RectangleSelector.map_world_to_model RectangleSelector.remove_event_handler + RectangleSelector.remove_imgui_right_click RectangleSelector.rotate + RectangleSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/RectangleSelectors.rst b/docs/source/api/selectors/RectangleSelectors.rst index ae9d562c3..b1a7e4e78 100644 --- a/docs/source/api/selectors/RectangleSelectors.rst +++ b/docs/source/api/selectors/RectangleSelectors.rst @@ -27,9 +27,9 @@ Properties RectangleSelectors.block_handlers RectangleSelectors.deleted RectangleSelectors.event_handlers + RectangleSelectors.imgui_right_click RectangleSelectors.name RectangleSelectors.offset - RectangleSelectors.right_click_menu RectangleSelectors.rotation RectangleSelectors.scale RectangleSelectors.selection @@ -46,6 +46,7 @@ Methods RectangleSelectors.add_axes RectangleSelectors.add_event_handler RectangleSelectors.append + RectangleSelectors.append_imgui_right_click RectangleSelectors.clear RectangleSelectors.clear_event_handlers RectangleSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods RectangleSelectors.map_world_to_model RectangleSelectors.remove RectangleSelectors.remove_event_handler + RectangleSelectors.remove_imgui_right_click RectangleSelectors.rotate + RectangleSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/SelectorCollection.rst b/docs/source/api/selectors/SelectorCollection.rst index 9b4d24929..2b6495ad5 100644 --- a/docs/source/api/selectors/SelectorCollection.rst +++ b/docs/source/api/selectors/SelectorCollection.rst @@ -27,9 +27,9 @@ Properties SelectorCollection.block_handlers SelectorCollection.deleted SelectorCollection.event_handlers + SelectorCollection.imgui_right_click SelectorCollection.name SelectorCollection.offset - SelectorCollection.right_click_menu SelectorCollection.rotation SelectorCollection.scale SelectorCollection.selection @@ -46,6 +46,7 @@ Methods SelectorCollection.add_axes SelectorCollection.add_event_handler SelectorCollection.append + SelectorCollection.append_imgui_right_click SelectorCollection.clear SelectorCollection.clear_event_handlers SelectorCollection.format_pick_info @@ -53,5 +54,7 @@ Methods SelectorCollection.map_world_to_model SelectorCollection.remove SelectorCollection.remove_event_handler + SelectorCollection.remove_imgui_right_click SelectorCollection.rotate + SelectorCollection.set_imgui_right_click diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst deleted file mode 100644 index d22ca3900..000000000 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ /dev/null @@ -1,58 +0,0 @@ -.. _api.HistogramLUTTool: - -HistogramLUTTool -**************** - -================ -HistogramLUTTool -================ -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool.alpha - HistogramLUTTool.alpha_mode - HistogramLUTTool.axes - HistogramLUTTool.block_events - HistogramLUTTool.block_handlers - HistogramLUTTool.cmap - HistogramLUTTool.deleted - HistogramLUTTool.event_handlers - HistogramLUTTool.histogram - HistogramLUTTool.images - HistogramLUTTool.name - HistogramLUTTool.offset - HistogramLUTTool.right_click_menu - HistogramLUTTool.rotation - HistogramLUTTool.scale - HistogramLUTTool.supported_events - HistogramLUTTool.tooltip_format - HistogramLUTTool.visible - HistogramLUTTool.vmax - HistogramLUTTool.vmin - HistogramLUTTool.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool.add_axes - HistogramLUTTool.add_event_handler - HistogramLUTTool.clear_event_handlers - HistogramLUTTool.format_pick_info - HistogramLUTTool.map_model_to_world - HistogramLUTTool.map_world_to_model - HistogramLUTTool.remove_event_handler - HistogramLUTTool.rotate - diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst index 2bff8fb50..7a06fd5a0 100644 --- a/docs/source/api/tools/index.rst +++ b/docs/source/api/tools/index.rst @@ -4,7 +4,6 @@ Tools .. toctree:: :maxdepth: 1 - HistogramLUTTool TextBox Tooltip Cursor diff --git a/docs/source/api/ui/BaseGUI.rst b/docs/source/api/ui/BaseGUI.rst deleted file mode 100644 index 788e1414a..000000000 --- a/docs/source/api/ui/BaseGUI.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _api.BaseGUI: - -BaseGUI -******* - -======= -BaseGUI -======= -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - BaseGUI - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - BaseGUI.update - diff --git a/docs/source/api/ui/EdgeWindow.rst b/docs/source/api/ui/EdgeWindow.rst deleted file mode 100644 index 5835ab847..000000000 --- a/docs/source/api/ui/EdgeWindow.rst +++ /dev/null @@ -1,38 +0,0 @@ -.. _api.EdgeWindow: - -EdgeWindow -********** - -========== -EdgeWindow -========== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow.height - EdgeWindow.location - EdgeWindow.size - EdgeWindow.width - EdgeWindow.x - EdgeWindow.y - -Methods -~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow.draw_window - EdgeWindow.get_rect - EdgeWindow.update - diff --git a/docs/source/api/ui/ImguiBase.rst b/docs/source/api/ui/ImguiBase.rst new file mode 100644 index 000000000..078ca67c6 --- /dev/null +++ b/docs/source/api/ui/ImguiBase.rst @@ -0,0 +1,30 @@ +.. _api.ImguiBase: + +ImguiBase +********* + +========= +ImguiBase +========= +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + ImguiBase + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + ImguiBase.draw + diff --git a/docs/source/api/ui/ImguiPopup.rst b/docs/source/api/ui/ImguiPopup.rst new file mode 100644 index 000000000..481bccc6a --- /dev/null +++ b/docs/source/api/ui/ImguiPopup.rst @@ -0,0 +1,37 @@ +.. _api.ImguiPopup: + +ImguiPopup +********** + +========== +ImguiPopup +========== +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup.graphic + ImguiPopup.is_open + ImguiPopup.parent + ImguiPopup.subplot + ImguiPopup.window_flags + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup.draw + ImguiPopup.open + ImguiPopup.update + diff --git a/docs/source/api/ui/ImguiWindow.rst b/docs/source/api/ui/ImguiWindow.rst new file mode 100644 index 000000000..b921d299d --- /dev/null +++ b/docs/source/api/ui/ImguiWindow.rst @@ -0,0 +1,38 @@ +.. _api.ImguiWindow: + +ImguiWindow +*********** + +=========== +ImguiWindow +=========== +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow.height + ImguiWindow.location + ImguiWindow.size + ImguiWindow.width + ImguiWindow.window_flags + ImguiWindow.x + ImguiWindow.y + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow.draw + ImguiWindow.update + diff --git a/docs/source/api/ui/Popup.rst b/docs/source/api/ui/Popup.rst deleted file mode 100644 index 5e924db94..000000000 --- a/docs/source/api/ui/Popup.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _api.Popup: - -Popup -***** - -===== -Popup -===== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - Popup - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - Popup.open - Popup.update - diff --git a/docs/source/api/ui/Window.rst b/docs/source/api/ui/Window.rst deleted file mode 100644 index 63c384261..000000000 --- a/docs/source/api/ui/Window.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _api.Window: - -Window -****** - -====== -Window -====== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Window_api - - Window - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Window_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Window_api - - Window.update - diff --git a/docs/source/api/ui/index.rst b/docs/source/api/ui/index.rst index 4f31e651a..471d05ad3 100644 --- a/docs/source/api/ui/index.rst +++ b/docs/source/api/ui/index.rst @@ -4,7 +4,6 @@ UI Bases .. toctree:: :maxdepth: 1 - BaseGUI - Window - EdgeWindow - Popup + ImguiBase + ImguiWindow + ImguiPopup diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst deleted file mode 100644 index fbafd4723..000000000 --- a/docs/source/api/widgets/ImageWidget.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _api.ImageWidget: - -ImageWidget -*********** - -=========== -ImageWidget -=========== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget.cmap - ImageWidget.current_index - ImageWidget.data - ImageWidget.figure - ImageWidget.frame_apply - ImageWidget.managed_graphics - ImageWidget.n_img_dims - ImageWidget.n_scrollable_dims - ImageWidget.ndim - ImageWidget.slider_dims - ImageWidget.window_funcs - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget.add_event_handler - ImageWidget.clear_event_handlers - ImageWidget.close - ImageWidget.remove_event_handler - ImageWidget.reset_vmin_vmax - ImageWidget.reset_vmin_vmax_frame - ImageWidget.set_data - ImageWidget.show - diff --git a/docs/source/api/widgets/index.rst b/docs/source/api/widgets/index.rst index c60b3c485..fbebc87ec 100644 --- a/docs/source/api/widgets/index.rst +++ b/docs/source/api/widgets/index.rst @@ -5,4 +5,3 @@ Widgets :maxdepth: 1 NDWidget - ImageWidget diff --git a/docs/source/conf.py b/docs/source/conf.py index ead9f05c4..0ffecdcc3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,6 +21,7 @@ EXAMPLES_DIR = Path.joinpath(ROOT_DIR, "examples") sys.path.insert(0, str(ROOT_DIR)) +sys.path.insert(0, str(Path(__file__).parent.joinpath("_ext"))) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -42,6 +43,7 @@ "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", + "imgui_docs", ] sphinx_gallery_conf = { @@ -56,7 +58,7 @@ "../../examples/image", "../../examples/image_volume", "../../examples/heatmap", - "../../examples/image_widget", + # "../../examples/image_widget", "../../examples/gridplot", "../../examples/window_layouts", "../../examples/controllers", @@ -69,6 +71,7 @@ "../../examples/events", "../../examples/selection_tools", "../../examples/spaces_transforms", + "../../examples/ndwidget", "../../examples/machine_learning", "../../examples/guis", "../../examples/ipywidgets", diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 5ca237f57..5ad6dbb04 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -298,7 +298,12 @@ def main(): ) ############################################################################## # ** GraphicFeature classes ** # - feature_classes = [getattr(features, f) for f in features.__all__] + # `features.__all__` also exports type aliases, such as TupleYUV, which has no docs page + feature_classes = [ + getattr(features, f) + for f in features.__all__ + if inspect.isclass(getattr(features, f)) + ] feature_class_names = [f.__name__ for f in feature_classes] @@ -427,7 +432,7 @@ def main(): ) ############################################################################## # ** UI classes ** # - ui_classes = [ui.BaseGUI, ui.Window, ui.EdgeWindow, ui.Popup] + ui_classes = [ui.ImguiBase, ui.ImguiWindow, ui.ImguiPopup] ui_class_names = [cls.__name__ for cls in ui_classes] diff --git a/docs/source/imgui/guide.rst b/docs/source/imgui/guide.rst new file mode 100644 index 000000000..4ba55bf0d --- /dev/null +++ b/docs/source/imgui/guide.rst @@ -0,0 +1,270 @@ +imgui UIs +========= + +`imgui `_ UIs are rendered directly onto the same canvas as the ``Figure``, so +the same UI code runs on every GUI backend: glfw, Qt, wx, and jupyter. + +imgui support requires ``imgui-bundle``, see the installation section of the user guide. When ``imgui-bundle`` is +installed ``fastplotlib.Figure`` is an ``ImguiFigure``, and every subplot gets a toolbar and a standard right-click +menu. + +There are two things you can add to a ``Figure``: + +* ``ImguiWindow`` - a window drawn within the Figure. It can float over the plots, be fixed to a rect, or occupy space + on an edge of the Figure or of a Subplot. +* ``ImguiPopup`` - a popup opened by a right-click on the Figure, a Subplot, or a Graphic. + +Both are written in the same way, either as a function or as a subclass. + +Floating and fixed windows +-------------------------- + +A floating window is drawn over the plots. imgui sizes it to fit its contents, it appears at the top left of the +canvas, and the user can move, resize, and collapse it. The function draws the imgui elements and is called on every +render, the object it is added to is an optional argument:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure(size=(700, 560)) + figure[0, 0].add_line(np.random.rand(100), name="line") + + @figure.add_imgui_window(location="floating") + def gui(fig): + line = fig[0, 0]["line"] + + changed, thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + if changed: + line.thickness = thickness + + if imgui.button("randomize"): + line.data[:, 1] = np.random.rand(100) + +``add_imgui_window`` can also be given the function directly instead of decorating it, which is useful when the same +function is used more than once:: + + figure.add_imgui_window(gui, location="floating") + +A window can instead be fixed to a ``rect`` of the canvas, ``(x, y, width, height)``, or to an ``extent``, +``(xmin, xmax, ymin, ymax)``. These are fractional if the width and height are ``<= 1``, and in pixels otherwise. A +fixed window cannot be moved, resized, or collapsed:: + + @figure.add_imgui_window(extent=(0.6, 0.98, 0.05, 0.25)) + def gui(): + imgui.text("fixed to a fractional extent") + +Figure edge windows +------------------- + +An edge window occupies canvas space along one edge of the Figure, so it never covers the plots. ``location`` is one of +``"left"``, ``"right"``, ``"top"``, ``"bottom"``, and ``size`` is the thickness in pixels, which is required:: + + @figure.add_imgui_window(location="right", size=200, title="controls") + def gui(fig): + ... + +If ``title`` is not given no title bar is drawn. The "bottom" and "right" Figure edge windows can be resized by +dragging their inner border, and collapsed by double-clicking it. + +Subplot edge windows +-------------------- + +You can add imgui windows that are confined to a subplot edge:: + + @figure[0, 0].add_imgui_window(location="right", size=130, title="image") + def gui(subplot): + if imgui.button("noise"): + subplot["image"].data = np.random.rand(128, 128) + +Each subplot also has a toolbar, an imgui window at the ``"toolbar"`` location that you can append elements to:: + + from imgui_bundle import icons_fontawesome_6 as fa + + @figure[0, 0].append_imgui_window(location="toolbar") + def toolbar_extra(subplot): + imgui.same_line() + _, subplot.axes.visible = imgui.checkbox(fa.ICON_FA_RULER_COMBINED, subplot.axes.visible) + +``subplot.toolbar = False`` hides it, and ``add_imgui_window(location="toolbar")`` replaces it. + +Appending, replacing, and removing +---------------------------------- + +Windows are keyed by location, and ``add_imgui_window`` replaces the window at that location. +``append_imgui_window`` adds more UI elements to the window that is already there, it raises if there is none:: + + @figure.append_imgui_window(location="right") + def more(fig): + imgui.text("appended below the elements of the existing window") + +``remove_imgui_window`` removes and returns the window at a location, which can be added again later:: + + window = figure.remove_imgui_window("right") + +``figure.imgui_windows`` and ``subplot.imgui_windows`` return the windows keyed by location. + +Subclassing ``ImguiWindow`` +--------------------------- + +Subclass ``ImguiWindow`` and implement ``update()`` when you need something more complex, such as a UI that keeps +state. Pass what the UI needs into ``__init__``, an instance is not bound to a Figure until it is added:: + + from fastplotlib.ui import ImguiWindow + + class Controls(ImguiWindow): + def __init__(self, line): + super().__init__() + + self._line = line + self._ys = line.data[:, 1].copy() + self._amplitude = 1.0 + + def update(self): + changed, self._amplitude = imgui.slider_float( + "amplitude", v=self._amplitude, v_min=0.1, v_max=10.0 + ) + if changed: + self._line.data[:, 1] = self._ys * self._amplitude + + figure.add_imgui_window(Controls(line), location="right", size=200, title="controls") + +Within ``update()`` the window's pixel rect is available as ``x``, ``y``, ``width``, and ``height``. ``size`` is +settable, and setting it on an edge or toolbar window triggers a re-layout of the Figure. + +``fastplotlib.ui.ChangeFlag`` is useful when several elements modify the same thing. It is a bool that stays ``True`` +once it has been set to ``True``:: + + from fastplotlib.ui import ChangeFlag + + changed = ChangeFlag(False) + changed.value, vmin = imgui.slider_float("vmin", v=image.vmin, v_min=0, v_max=255) + changed.value, vmax = imgui.slider_float("vmax", v=image.vmax, v_min=0, v_max=255) + + if changed: + image.vmin, image.vmax = vmin, vmax + +For full control of the imgui window, override ``draw()`` instead of ``update()``. You are then responsible for +creating the window with ``imgui.begin()`` and ``imgui.end()``, and ``update()`` is not used. This is how you use +window flags that must be set when the window is created, such as ``imgui.WindowFlags_.menu_bar`` for a menu bar, +see :ref:`imgui.WindowFlags_ `. The examples gallery has a menu bar example. + +Right-click popups +------------------ + +A popup is opened by a right-click. It is not restricted to menu items, any imgui elements can be used. + +A popup can be set on the Figure, where it replaces the standard right-click menu, on a Subplot, or on a Graphic. The +most specific one wins: the popup of the graphic under the pointer, else the popup of the subplot that was clicked, +else the popup of the Figure:: + + @figure.set_imgui_right_click() + def popup(fig): + if imgui.menu_item("autoscale all", "", False)[0]: + for subplot in fig: + subplot.auto_scale() + + @figure[0, 1].set_imgui_right_click() + def subplot_popup(subplot): + imgui.text(f"subplot: {subplot.name}") + +A popup takes the object it is set on as an optional argument, and the function can be passed directly instead of +decorating. Each call wraps the function in its own popup, so the same function can be set on any number of graphics:: + + def contrast(image): + changed, vals = imgui.slider_float2("vmin / vmax", (image.vmin, image.vmax), 0, 255) + if changed: + image.vmin, image.vmax = vals + + img1.set_imgui_right_click(contrast) + img2.set_imgui_right_click(contrast) + +Only one popup can be set on an object. A graphic must be added to a subplot of an ``ImguiFigure`` before a popup can +be set on it. ``append_imgui_right_click`` adds more UI elements to the popup that is set, +``remove_imgui_right_click`` removes and returns it, and ``imgui_right_click`` returns the popup that is set. + +Extending the standard right-click menu +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Figure's popup is a ``StandardRightClickMenu``. Append to it to keep its items and add your own:: + + @figure.append_imgui_right_click() + def extra_items(fig): + imgui.separator() + _, fig.imgui_show_fps = imgui.checkbox("show fps", fig.imgui_show_fps) + +Subclassing ``ImguiPopup`` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Subclass ``ImguiPopup`` and implement ``update()``, which contains only the imgui elements. ``subplot`` and ``graphic`` +are what the popup was opened on, ``graphic`` is ``None`` if the click was not on a graphic, and ``parent`` is the +object the popup is set on:: + + from fastplotlib.ui import ImguiPopup + + class MyPopup(ImguiPopup): + def update(self): + imgui.text(f"subplot: {self.subplot.name}") + + if imgui.menu_item("autoscale", "", False)[0]: + self.subplot.auto_scale() + + figure.set_imgui_right_click(MyPopup()) + +To keep the standard items, subclass ``StandardRightClickMenu`` and call ``super().update()``:: + + from fastplotlib.ui import StandardRightClickMenu + + class MyMenu(StandardRightClickMenu): + def update(self): + super().update() + + imgui.separator() + if imgui.menu_item("my item", "", False)[0]: + ... + +``window_flags`` can be passed to ``set_imgui_right_click`` and is a settable property, see +:ref:`imgui.WindowFlags_ `. ``is_open`` tells you whether the popup is currently open. + +A window that must stay open after the popup closes cannot be drawn in ``update()``, which only runs while the popup is +open. Override ``draw()`` and draw it after the popup:: + + class MyPopup(ImguiPopup): + def __init__(self): + super().__init__() + self._window_open = False + + def update(self): + if imgui.menu_item("Open window", "", False)[0]: + self._window_open = True + + def draw(self): + super().draw() + + if self._window_open: + _, self._window_open = imgui.begin("my window", True) + imgui.text("stays open after the popup closes") + imgui.end() + +Built-in imgui UIs +------------------ + +* ``SubplotToolbar`` - the toolbar of each subplot. +* ``StandardRightClickMenu`` - the Figure's default right-click popup: fps, autoscale, center, maintain aspect, flip + axes, grids, FOV, and controller options. +* ``ImguiColorbar`` - an ``ImguiWindow`` that shows a colorbar for one or more images, with draggable vmin and vmax, a + colormap picker, gamma, and an optional precomputed histogram:: + + from fastplotlib.ui import ImguiColorbar + + colorbar = ImguiColorbar(images=image, histogram=np.histogram(data, bins=100)) + figure[0, 0].add_imgui_window(colorbar, location="right", size=100) + +Writing imgui elements +---------------------- + +fastplotlib does not wrap imgui, you call ``imgui_bundle`` directly, so any imgui element can be used. The +:doc:`imgui element reference ` documents each element as it exists in ``imgui_bundle``, with +its signature, its arguments, its flags, and an example of what it looks like. + +The ImGUI section of the examples gallery has complete examples. diff --git a/docs/source/imgui/index.rst b/docs/source/imgui/index.rst new file mode 100644 index 000000000..f29f86dbf --- /dev/null +++ b/docs/source/imgui/index.rst @@ -0,0 +1,11 @@ +imgui +***** + +The guide walks you through how to use and integrate imgui with fastplotlib. The reference covers the imgui +elements themselves. + +.. toctree:: + :maxdepth: 2 + + guide + reference/index diff --git a/docs/source/imgui/reference/elements.rst b/docs/source/imgui/reference/elements.rst new file mode 100644 index 000000000..b5c17e1d0 --- /dev/null +++ b/docs/source/imgui/reference/elements.rst @@ -0,0 +1,3284 @@ +Elements +======== + +The imgui elements as they exist in ``imgui_bundle``. Each element is shown with the code that produced its +image, which runs as it is written. See the :doc:`imgui guide ` for adding a UI to a Figure. + +An argument typed ``ImVec2`` or ``ImVec4`` also takes a tuple or a list. + +The examples use ``imgui``, ``icons_fontawesome_6 as fa`` and ``numpy as np``. + +Text +---- + +Text elements are read-only, they display a value that the user cannot edit. + +text +^^^^ + +.. imgui-signature:: text + +**Parameters** + +* ``fmt`` - the text to draw + +.. imgui-example:: + + n_peaks = 137 + + imgui.text(f"peaks found: {n_peaks}") + +text_colored +^^^^^^^^^^^^ + +.. imgui-signature:: text_colored + +**Parameters** + +* ``col`` - text color, ``(r, g, b, a)`` in ``0.0`` to ``1.0`` +* ``fmt`` - the text to draw + +.. imgui-example:: + + vmin, vmax = 180.0, 60.0 + + if vmin > vmax: + imgui.text_colored((1.0, 0.3, 0.3, 1.0), f"{fa.ICON_FA_TRIANGLE_EXCLAMATION} vmin > vmax") + +text_disabled +^^^^^^^^^^^^^ + +.. imgui-signature:: text_disabled + +**Parameters** + +* ``fmt`` - the text to draw + +.. imgui-example:: + + selected = None + + imgui.text("selection:") + imgui.same_line() + + if selected is None: + imgui.text_disabled("none") + else: + imgui.text(selected) + +text_wrapped +^^^^^^^^^^^^ + +.. imgui-signature:: text_wrapped + +**Parameters** + +* ``fmt`` - the text to draw, wrapped at the right edge of the window + +.. imgui-example:: + :width: 220 + + imgui.text_wrapped("the filter runs on the full frame, it can take a few seconds for large images") + +label_text +^^^^^^^^^^ + +.. imgui-signature:: label_text + +**Parameters** + +* ``label`` - drawn to the right of the value, aligned the same way as the label of a slider or an input +* ``fmt`` - the value to draw + +.. imgui-example:: + + data = np.random.randint(0, 4096, (512, 512), dtype=np.uint16) + + imgui.label_text("shape", str(data.shape)) + imgui.label_text("dtype", str(data.dtype)) + imgui.label_text("range", f"{data.min()} - {data.max()}") + +bullet_text +^^^^^^^^^^^ + +.. imgui-signature:: bullet_text + +**Parameters** + +* ``fmt`` - the text to draw after the bullet + +.. imgui-example:: + + imgui.text("controller:") + imgui.bullet_text("left click drag to pan") + imgui.bullet_text("right click drag to zoom") + imgui.bullet_text("scroll to zoom about the cursor") + +separator_text +^^^^^^^^^^^^^^ + +.. imgui-signature:: separator_text + +**Parameters** + +* ``label`` - the text to draw in the separator + +.. imgui-example:: + + thickness, sigma = 4.0, 1.0 + + imgui.separator_text("line") + changed, thickness = imgui.slider_float("thickness", v=thickness, v_min=1.0, v_max=20.0) + + imgui.separator_text("image") + changed, sigma = imgui.slider_float("gaussian sigma", v=sigma, v_min=0.1, v_max=10.0) + +Widgets +------- + +button +^^^^^^ + +.. imgui-signature:: button + +**Parameters** + +* ``label`` - drawn on the button, ``"##hidden"`` suppresses it +* ``size`` - ``(width, height)``, a zero component is sized to the label, a negative one fills the available space + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + if imgui.button("autoscale"): + print("autoscale clicked") + + if imgui.button(fa.ICON_FA_TRASH): + print("trash clicked") + if imgui.is_item_hovered(): + imgui.set_tooltip("remove all graphics") + +small_button +^^^^^^^^^^^^ + +.. imgui-signature:: small_button + +**Parameters** + +* ``label`` - drawn on the button + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + imgui.text(f"vmin {vmin:.0f}, vmax {vmax:.0f}") + imgui.same_line() + + if imgui.small_button("reset"): + vmin, vmax = 0.0, 255.0 + +arrow_button +^^^^^^^^^^^^ + +.. imgui-signature:: arrow_button + +**Parameters** + +* ``str_id`` - identifies the button, it is not drawn +* ``dir`` - ``imgui.Dir.left``, ``right``, ``up`` or ``down`` + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + channel, n_channels = 1, 4 + + if imgui.arrow_button("previous", imgui.Dir.left): + channel = max(0, channel - 1) + + imgui.same_line() + imgui.text(f"channel {channel}") + + imgui.same_line() + if imgui.arrow_button("next", imgui.Dir.right): + channel = min(n_channels - 1, channel + 1) + +invisible_button +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: invisible_button + +**Parameters** + +* ``str_id`` - identifies the button, nothing is drawn +* ``size`` - ``(width, height)`` of the area that responds to the pointer + +**Returns:** ``True`` on the frame the button is clicked + +An invisible button gives the pointer behavior of a button to an area that you draw yourself. The pointer is over the +button in the image below, so the bar is drawn in its highlighted color. + +.. imgui-example:: + :interact: hover 40 20 + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + imgui.invisible_button("threshold-bar", (120, 24)) + + color = (1.0, 0.8, 0.2, 1.0) if imgui.is_item_hovered() else (0.4, 0.4, 0.4, 1.0) + draw_list.add_rect_filled( + position, (position.x + 120, position.y + 24), imgui.color_convert_float4_to_u32(color) + ) + +checkbox +^^^^^^^^ + +.. imgui-signature:: checkbox + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``v`` - the current state + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + axes_visible, grid_visible = True, False + + changed, axes_visible = imgui.checkbox("axes", axes_visible) + changed, grid_visible = imgui.checkbox("grid", grid_visible) + +checkbox_flags +^^^^^^^^^^^^^^ + +.. imgui-signature:: checkbox_flags + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``flags`` - the ``int`` that holds the bits +* ``flags_value`` - the bit that this checkbox sets and clears + +**Returns:** ``(changed, flags)`` + +The box is checked when the bit is set, and is drawn filled when ``flags_value`` holds several bits and only some of +them are set. + +.. imgui-example:: + + slider_flags = int(imgui.SliderFlags_.logarithmic) + + changed, slider_flags = imgui.checkbox_flags( + "logarithmic", slider_flags, int(imgui.SliderFlags_.logarithmic) + ) + changed, slider_flags = imgui.checkbox_flags( + "no input", slider_flags, int(imgui.SliderFlags_.no_input) + ) + +radio_button +^^^^^^^^^^^^ + +.. imgui-signature:: radio_button + +**Parameters** + +* ``label`` - drawn to the right of the button +* ``active`` - whether this button is the selected one +* ``v``, ``v_button`` - the variable that holds the selection, and the value of this button + +**Returns:** ``True`` on the frame the button is clicked, or ``(changed, v)`` for the second form + +Use radio buttons for a small number of options that are all worth showing, a combo box is better for a long list. + +.. imgui-example:: + + mode = 1 + + for i, label in enumerate(["line", "scatter", "heatmap"]): + if imgui.radio_button(label, mode == i): + mode = i + +progress_bar +^^^^^^^^^^^^ + +.. imgui-signature:: progress_bar + +**Parameters** + +* ``fraction`` - ``0.0`` to ``1.0`` +* ``size_arg`` - ``(width, height)``, the default fills the available width +* ``overlay`` - text drawn on the bar, the percentage is drawn if it is not given + +.. imgui-example:: + :width: 280 + + n_done, n_frames = 317, 500 + + imgui.progress_bar(n_done / n_frames, overlay=f"{n_done} / {n_frames} frames") + +bullet +^^^^^^ + +.. imgui-signature:: bullet + +**Parameters** + +none + +.. imgui-example:: + + shape = (500, 512, 512) + + imgui.bullet() + imgui.text(f"{shape[0]} frames") + + imgui.bullet() + imgui.text(f"{shape[1]} x {shape[2]} pixels") + +Sliders +------- + +A slider is dragged between a lower and an upper bound. A drag has no bound by default and changes its value by how +far the pointer moves, which suits a value with no natural range. Ctrl+click either of them to type a value instead. + +``format`` is a printf format, it is applied to the value drawn on the element, e.g. ``"%.1f px"``. + +slider_float +^^^^^^^^^^^^ + +.. imgui-signature:: slider_float + +**Parameters** + +* ``label`` - drawn to the right of the slider, ``"##hidden"`` suppresses it +* ``v`` - the current value +* ``v_min``, ``v_max`` - the bounds, the value is clamped to them +* ``format`` - printf format of the value drawn on the slider + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + thickness = 4.0 + + changed, thickness = imgui.slider_float("thickness", v=thickness, v_min=1.0, v_max=20.0) + +slider_float2 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float2 + +Two values on one row, sharing one pair of bounds. Pass a list and use the list that comes back. + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to both components +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + vmin_vmax = [12.0, 208.0] + + changed, vmin_vmax = imgui.slider_float2("vmin / vmax", vmin_vmax, 0.0, 255.0, format="%.0f") + +slider_float3 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float3 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + spacing = [1.0, 1.0, 3.0] + + changed, spacing = imgui.slider_float3("voxel spacing", spacing, 0.1, 10.0, format="%.2f") + +slider_float4 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float4 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + extent = [0.1, 0.9, 0.1, 0.9] + + changed, extent = imgui.slider_float4("extent", extent, 0.0, 1.0, format="%.2f") + +slider_int +^^^^^^^^^^ + +.. imgui-signature:: slider_int + +**Parameters** + +* ``label`` - drawn to the right of the slider +* ``v`` - the current value +* ``v_min``, ``v_max`` - the bounds, the value is clamped to them +* ``format`` - printf format of the value drawn on the slider + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + n_bins = 100 + + changed, n_bins = imgui.slider_int("bins", v=n_bins, v_min=10, v_max=500) + +slider_int2 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int2 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to both components +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + crop = [64, 448] + + changed, crop = imgui.slider_int2("crop rows", crop, 0, 512) + +slider_int3 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int3 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + stride = [1, 2, 2] + + changed, stride = imgui.slider_int3("stride", stride, 1, 8) + +slider_int4 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int4 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + roi = [64, 64, 256, 256] + + changed, roi = imgui.slider_int4("roi", roi, 0, 512) + +slider_angle +^^^^^^^^^^^^ + +.. imgui-signature:: slider_angle + +The value is in radians, the bounds and the value drawn on the slider are in degrees. + +**Parameters** + +* ``label`` - drawn to the right of the slider +* ``v_rad`` - the current angle, in radians +* ``v_degrees_min``, ``v_degrees_max`` - the bounds, in degrees +* ``format`` - printf format of the angle drawn on the slider + +**Returns:** ``(changed, v_rad)`` + +.. imgui-example:: + + rotation = 0.6 + + changed, rotation = imgui.slider_angle("rotation", v_rad=rotation, v_degrees_min=-180, v_degrees_max=180) + +drag_float +^^^^^^^^^^ + +.. imgui-signature:: drag_float + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v`` - the current value +* ``v_speed`` - how much the value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the value drawn on the element + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + sigma = 1.4 + + changed, sigma = imgui.drag_float("gaussian sigma", v=sigma, v_speed=0.05, v_min=0.1, v_max=20.0) + +drag_float2 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float2 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to both components, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + origin = [0.0, 0.0] + + changed, origin = imgui.drag_float2("origin", origin, v_speed=0.5) + +drag_float3 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float3 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to every component, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + offset = [0.0, 0.0, 0.0] + + changed, offset = imgui.drag_float3("offset", offset, v_speed=0.5) + +drag_float4 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float4 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to every component, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + bounds = [0.0, 512.0, 0.0, 512.0] + + changed, bounds = imgui.drag_float4("bounds", bounds, v_speed=1.0, format="%.0f") + +drag_float_range2 +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: drag_float_range2 + +Two values that cannot cross, the lower one is dragged from the left half and the upper one from the right half. + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v_current_min``, ``v_current_max`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the lower value +* ``format_max`` - printf format of the upper value, ``format`` is used for both if it is not given + +**Returns:** ``(changed, v_current_min, v_current_max)`` + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + changed, vmin, vmax = imgui.drag_float_range2( + "vmin / vmax", vmin, vmax, v_speed=1.0, v_min=0.0, v_max=255.0, format="%.0f" + ) + +drag_int +^^^^^^^^ + +.. imgui-signature:: drag_int + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v`` - the current value +* ``v_speed`` - how much the value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the value drawn on the element + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + window = 30 + + changed, window = imgui.drag_int("window size", v=window, v_speed=1.0, v_min=1, v_max=500) + +drag_int_range2 +^^^^^^^^^^^^^^^ + +.. imgui-signature:: drag_int_range2 + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v_current_min``, ``v_current_max`` - the current values, they cannot cross +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the lower value +* ``format_max`` - printf format of the upper value, ``format`` is used for both if it is not given + +**Returns:** ``(changed, v_current_min, v_current_max)`` + +.. imgui-example:: + + first, last = 40, 260 + + changed, first, last = imgui.drag_int_range2("frames", first, last, v_min=0, v_max=500) + +Input +----- + +Input elements are typed into. A slider or a drag is better for a value that is explored by eye, an input is better +for a value that is known. + +input_text +^^^^^^^^^^ + +.. imgui-signature:: input_text + +**Parameters** + +* ``label`` - drawn to the right of the field, ``"##hidden"`` suppresses it +* ``str`` - the current text +* ``callback``, ``user_data`` - an imgui input callback, for completion or filtering + +**Returns:** ``(changed, str)`` - ``changed`` is ``True`` on every keystroke unless +:ref:`imgui.InputTextFlags_ ` asks otherwise + +.. imgui-example:: + :interact: click 60 18; type "a" + + name = "roi-1" + + changed, name = imgui.input_text("graphic name", name) + +input_text_multiline +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: input_text_multiline + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``str`` - the current text +* ``size`` - ``(width, height)`` of the field, a zero component is a default size +* ``callback``, ``user_data`` - an imgui input callback + +**Returns:** ``(changed, str)`` + +.. imgui-example:: + + notes = "frame 42\nsaturated pixels\nrecheck vmax" + + changed, notes = imgui.input_text_multiline("notes", notes, (220, 70)) + +input_text_with_hint +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: input_text_with_hint + +The hint is drawn in the field while it is empty, use it instead of a label when there is no room for one. + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``hint`` - drawn in the field while ``str`` is empty +* ``str`` - the current text +* ``callback``, ``user_data`` - an imgui input callback + +**Returns:** ``(changed, str)`` + +.. imgui-example:: + + pattern = "" + + changed, pattern = imgui.input_text_with_hint("##filter", "filter graphics", pattern) + +input_float +^^^^^^^^^^^ + +.. imgui-signature:: input_float + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by, they are not drawn while it is ``0.0`` +* ``step_fast`` - amount used while ctrl is held +* ``format`` - printf format of the value in the field + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + threshold = 0.75 + + changed, threshold = imgui.input_float("threshold", v=threshold, step=0.05, step_fast=0.5) + +input_float2 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float2 + +Two, three, and four fields on one row. Pass a list and use the list that comes back. + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + pixel_size = [0.325, 0.325] + + changed, pixel_size = imgui.input_float2("pixel size (um)", pixel_size, format="%.3f") + +input_float3 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float3 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + origin = [0.0, 0.0, 0.0] + + changed, origin = imgui.input_float3("origin", origin, format="%.1f") + +input_float4 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float4 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + bounds = [0.0, 512.0, 0.0, 512.0] + + changed, bounds = imgui.input_float4("bounds", bounds, format="%.0f") + +input_int +^^^^^^^^^ + +.. imgui-signature:: input_int + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by +* ``step_fast`` - amount used while ctrl is held + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + n_components = 8 + + changed, n_components = imgui.input_int("components", v=n_components, step=1, step_fast=10) + +input_int2 +^^^^^^^^^^ + +.. imgui-signature:: input_int2 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + shape = [512, 512] + + changed, shape = imgui.input_int2("output shape", shape) + +input_int3 +^^^^^^^^^^ + +.. imgui-signature:: input_int3 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + chunks = [1, 256, 256] + + changed, chunks = imgui.input_int3("chunks", chunks) + +input_int4 +^^^^^^^^^^ + +.. imgui-signature:: input_int4 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + roi = [64, 64, 256, 256] + + changed, roi = imgui.input_int4("roi", roi) + +input_double +^^^^^^^^^^^^ + +.. imgui-signature:: input_double + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by, they are not drawn while it is ``0.0`` +* ``step_fast`` - amount used while ctrl is held +* ``format`` - printf format of the value in the field + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + :width: 260 + + exposure = 0.008 + + changed, exposure = imgui.input_double("exposure (s)", v=exposure, step=0.001, format="%.4f") + +Selection +--------- + +combo +^^^^^ + +.. imgui-signature:: combo + +**Parameters** + +* ``label`` - drawn to the right of the box, ``"##hidden"`` suppresses it +* ``current_item`` - index of the selected item +* ``items`` - the items, as a sequence of strings +* ``popup_max_height_in_items`` - how many items the open list shows before it scrolls + +**Returns:** ``(changed, current_item)`` + +.. imgui-example:: + + mode, modes = 1, ["mip", "minip", "iso", "slice"] + + changed, mode = imgui.combo("render mode", mode, modes) + +The list is drawn while the box is open: + +.. imgui-example:: + :name: combo_open + :interact: click 60 18 + + mode, modes = 1, ["mip", "minip", "iso", "slice"] + + changed, mode = imgui.combo("render mode", mode, modes) + +begin_combo +^^^^^^^^^^^ + +.. imgui-signature:: begin_combo + +Use these instead of ``combo`` when the items are not plain strings, the body draws whatever it likes. Call +``end_combo`` only when ``begin_combo`` returned ``True``. + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``preview_value`` - drawn in the box while it is closed + +.. imgui-example:: + :name: begin_combo + :interact: click 60 18 + + selected, graphics = "line-1", ["line-1", "line-2", "scatter-1"] + + if imgui.begin_combo("graphic", selected): + for name in graphics: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + + imgui.end_combo() + +end_combo +^^^^^^^^^ + +.. imgui-signature:: end_combo + +Call it only when the matching ``begin_combo`` returned ``True``. + +**Parameters** + +none + +list_box +^^^^^^^^ + +.. imgui-signature:: list_box + +A list box shows several items at once, a combo box hides them until it is opened. + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``current_item`` - index of the selected item +* ``items`` - the items, as a sequence of strings +* ``height_in_items`` - how many items are visible before the box scrolls + +**Returns:** ``(changed, current_item)`` + +.. imgui-example:: + + selected, graphics = 0, ["line-1", "line-2", "scatter-1", "image-1"] + + changed, selected = imgui.list_box("graphics", selected, graphics, height_in_items=4) + +begin_list_box +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_list_box + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``size`` - ``(width, height)``, a zero component is a default size + +.. imgui-example:: + :name: begin_list_box + + selected, graphics = "line-1", ["line-1", "line-2", "scatter-1"] + + if imgui.begin_list_box("graphics", (160, 70)): + for name in graphics: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + + imgui.end_list_box() + +end_list_box +^^^^^^^^^^^^ + +.. imgui-signature:: end_list_box + +Call it only when the matching ``begin_list_box`` returned ``True``. + +**Parameters** + +none + +selectable +^^^^^^^^^^ + +.. imgui-signature:: selectable + +A row of text that can be selected, and the item to build lists out of. + +**Parameters** + +* ``label`` - drawn in the row +* ``p_selected`` - whether this row is drawn as selected +* ``size`` - ``(width, height)``, a zero component fills the available width + +**Returns:** ``(clicked, p_selected)`` + +.. imgui-example:: + + selected = "scatter-1" + + for name in ["line-1", "line-2", "scatter-1"]: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + +Color +----- + +A color is a list of floats in ``0.0`` to ``1.0``, three of them for RGB and four for RGBA. The ``3`` and ``4`` +variants differ only in whether they include alpha. + +color_edit3 +^^^^^^^^^^^ + +.. imgui-signature:: color_edit3 + +A row of numeric fields with a color square at its right end. Clicking the square opens a picker, right-clicking it +opens a menu of display options. + +**Parameters** + +* ``label`` - drawn to the right of the fields, ``"##hidden"`` suppresses it +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.9, 0.3, 0.2] + + changed, color = imgui.color_edit3("line color", color) + +color_edit4 +^^^^^^^^^^^ + +.. imgui-signature:: color_edit4 + +``color_edit3`` with an alpha field. + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.9, 0.3, 0.2, 0.5] + + changed, color = imgui.color_edit4("fill color", color) + +color_picker3 +^^^^^^^^^^^^^ + +.. imgui-signature:: color_picker3 + +The full picker, drawn inline. ``color_edit3`` is the compact element and opens this in a popup when its square is +clicked. + +**Parameters** + +* ``label`` - drawn above the picker +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.2, 0.6, 0.95] + + changed, color = imgui.color_picker3("##picker", color) + +color_picker4 +^^^^^^^^^^^^^ + +.. imgui-signature:: color_picker4 + +``color_picker3`` with an alpha bar. + +**Parameters** + +* ``label`` - drawn to the right of the picker +* ``col`` - the current color +* ``ref_col`` - a second color drawn beside the current one, to compare against + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.2, 0.6, 0.95, 0.7] + + changed, color = imgui.color_picker4("##picker4", color) + +color_button +^^^^^^^^^^^^ + +.. imgui-signature:: color_button + +**Parameters** + +* ``desc_id`` - identifies the button, and is shown in its tooltip +* ``col`` - the color to draw, ``(r, g, b, a)`` +* ``size`` - ``(width, height)``, a zero component is a square the height of one row + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + for name, color in [("magenta", (1.0, 0.0, 1.0, 1.0)), ("cyan", (0.0, 1.0, 1.0, 1.0))]: + if imgui.color_button(name, color, size=(40, 20)): + print(f"{name} clicked") + + imgui.same_line() + imgui.text(name) + +set_color_edit_options +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_color_edit_options + +Sets the defaults for every color element that follows, so each one does not have to pass the same flags. Call it once +when the UI is created. + +**Parameters** + +* ``flags`` - the options to apply + +.. imgui-example:: + + imgui.set_color_edit_options(int(imgui.ColorEditFlags_.float) | int(imgui.ColorEditFlags_.display_hsv)) + + color = [0.9, 0.3, 0.2] + changed, color = imgui.color_edit3("line color", color) + +Trees and tabs +-------------- + +tree_node +^^^^^^^^^ + +.. imgui-signature:: tree_node + +Returns ``True`` while the node is open, in which case its contents are drawn and ``tree_pop`` must be called. The +node is opened and closed by the user, clicking the arrow. + +**Parameters** + +* ``label`` - drawn next to the arrow, and used as the id +* ``str_id``, ``ptr_id`` - an id given separately, for when the label is not unique or changes between frames +* ``fmt`` - the text to draw when an id is given separately + +.. imgui-example:: + :interact: click 20 18 + + if imgui.tree_node("image-1"): + imgui.text("512 x 512, uint16") + imgui.text("vmin 12, vmax 208") + imgui.tree_pop() + +tree_node_ex +^^^^^^^^^^^^ + +.. imgui-signature:: tree_node_ex + +``tree_node`` with flags, e.g. to have the node start open, or to draw it without an arrow. + +**Parameters** + +* ``label`` - drawn next to the arrow, and used as the id +* ``str_id``, ``ptr_id`` - an id given separately +* ``fmt`` - the text to draw when an id is given separately + +.. imgui-example:: + + if imgui.tree_node_ex("image-1", flags=imgui.TreeNodeFlags_.default_open): + imgui.text("512 x 512, uint16") + imgui.tree_pop() + +tree_pop +^^^^^^^^ + +.. imgui-signature:: tree_pop + +**Parameters** + +none + +collapsing_header +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: collapsing_header + +A header that shows and hides a section. Unlike a tree node it does not indent its contents and needs no +``tree_pop``, which makes it the element for grouping controls. + +**Parameters** + +* ``label`` - drawn in the header +* ``p_visible`` - when given, a close button is drawn and this is set to ``False`` when it is clicked + +**Returns:** ``True`` while the header is open, or ``(open, p_visible)`` for the second form + +.. imgui-example:: + + sigma = 1.4 + + if imgui.collapsing_header("filter", flags=imgui.TreeNodeFlags_.default_open): + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + + if imgui.collapsing_header("export"): + imgui.text("not shown while the header is closed") + +set_next_item_open +^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_item_open + +Opens or closes the next tree node or collapsing header from code, rather than waiting for the user to click it. + +**Parameters** + +* ``is_open`` - the state to set +* ``cond`` - an ``imgui.Cond_`` value, e.g. ``once`` to set it only the first time + +.. imgui-example:: + + imgui.set_next_item_open(True, imgui.Cond_.once) + + if imgui.tree_node("image-1"): + imgui.text("open because set_next_item_open was called") + imgui.tree_pop() + +begin_tab_bar +^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tab_bar + +**Parameters** + +* ``str_id`` - identifies the tab bar, it is not drawn + +.. imgui-example:: + :name: begin_tab_bar + + if imgui.begin_tab_bar("panels"): + if imgui.begin_tab_item("image")[0]: + imgui.text("512 x 512, uint16") + imgui.end_tab_item() + + if imgui.begin_tab_item("filter")[0]: + imgui.text("gaussian, sigma 1.4") + imgui.end_tab_item() + + imgui.end_tab_bar() + +end_tab_bar +^^^^^^^^^^^ + +.. imgui-signature:: end_tab_bar + +Call it only when the matching ``begin_tab_bar`` returned ``True``. + +**Parameters** + +none + +begin_tab_item +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tab_item + +**Parameters** + +* ``label`` - drawn on the tab +* ``p_open`` - when given, a close button is drawn on the tab and this is set to ``False`` when it is clicked + +**Returns:** ``(selected, p_open)``, draw the contents and call ``end_tab_item`` while ``selected`` + +.. imgui-example:: + :name: begin_tab_item + :interact: click 90 22 + + if imgui.begin_tab_bar("panels"): + for label in ["image", "filter", "export"]: + selected, _ = imgui.begin_tab_item(label) + if selected: + imgui.text(f"{label} panel") + imgui.end_tab_item() + + imgui.end_tab_bar() + +end_tab_item +^^^^^^^^^^^^ + +.. imgui-signature:: end_tab_item + +Call it only when the matching ``begin_tab_item`` returned ``True``. + +**Parameters** + +none + +tab_item_button +^^^^^^^^^^^^^^^ + +.. imgui-signature:: tab_item_button + +**Parameters** + +* ``label`` - drawn on the tab + +**Returns:** ``True`` on the frame the tab is clicked + +.. imgui-example:: + + if imgui.begin_tab_bar("panels"): + if imgui.begin_tab_item("image")[0]: + imgui.end_tab_item() + + if imgui.tab_item_button("+"): + print("add panel") + + imgui.end_tab_bar() + +Menus +----- + +A menu bar belongs to a window, so the window has to be created with ``imgui.WindowFlags_.menu_bar``. + +begin_menu_bar +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_menu_bar + +**Parameters** + +none + +.. imgui-example:: + :name: begin_menu_bar + :window: none + :interact: click 30 22 + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((220, 120)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("File"): + imgui.menu_item("Open", "Ctrl+O", False) + imgui.menu_item("Save", "Ctrl+S", False) + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +end_menu_bar +^^^^^^^^^^^^ + +.. imgui-signature:: end_menu_bar + +Call it only when the matching ``begin_menu_bar`` returned ``True``. + +**Parameters** + +none + +begin_main_menu_bar +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_main_menu_bar + +A bar pinned across the top of the canvas, it is not part of any window. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_main_menu_bar + :window: none + :size: 260, 90 + :interact: click 60 10 + + if imgui.begin_main_menu_bar(): + if imgui.begin_menu("File"): + imgui.menu_item("Open", "Ctrl+O", False) + imgui.end_menu() + + if imgui.begin_menu("Help"): + imgui.menu_item("Version", "", False) + imgui.end_menu() + + imgui.end_main_menu_bar() + +end_main_menu_bar +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: end_main_menu_bar + +Call it only when the matching ``begin_main_menu_bar`` returned ``True``. + +**Parameters** + +none + +begin_menu +^^^^^^^^^^ + +.. imgui-signature:: begin_menu + +Returns ``True`` while the menu is open, in which case its items are drawn and ``end_menu`` must be called. A +``begin_menu`` inside another one is a submenu. + +**Parameters** + +* ``label`` - drawn on the menu +* ``enabled`` - a disabled menu is drawn greyed out and cannot be opened + +.. imgui-example:: + :name: begin_menu + :window: none + :size: 300, 140 + :interact: click 30 22; hover 45 66 + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((240, 130)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("Graphics"): + imgui.menu_item("Add line", "", False) + + if imgui.begin_menu("Add image"): + imgui.menu_item("from file", "", False) + imgui.menu_item("from array", "", False) + imgui.end_menu() + + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +end_menu +^^^^^^^^ + +.. imgui-signature:: end_menu + +Call it only when the matching ``begin_menu`` returned ``True``. + +**Parameters** + +none + +menu_item +^^^^^^^^^ + +.. imgui-signature:: menu_item + +**Parameters** + +* ``label`` - drawn on the item +* ``shortcut`` - drawn right-aligned on the item, it is a label only and does not bind the key +* ``p_selected`` - when ``True`` a check mark is drawn, pass it a bool to make the item a toggle +* ``enabled`` - a disabled item is drawn greyed out and cannot be clicked + +**Returns:** ``(clicked, p_selected)`` + +.. imgui-example:: + :window: none + :interact: click 30 22 + + show_fps = True + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((230, 120)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("View"): + clicked, show_fps = imgui.menu_item("Show fps", "", show_fps) + imgui.menu_item("Autoscale", "A", False) + imgui.menu_item("Reset camera", "", False, enabled=False) + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +Popups and tooltips +------------------- + +A popup is opened by ``open_popup`` and drawn by ``begin_popup``, which returns ``True`` only while it is open. Both +have to be called for the same window, so calling ``open_popup`` from inside a menu does not open a popup that +``begin_popup`` draws outside of it. + +open_popup +^^^^^^^^^^ + +.. imgui-signature:: open_popup + +**Parameters** + +* ``str_id`` - identifies the popup, ``begin_popup`` is called with the same id +* ``id_`` - an integer id instead of a string one +* ``popup_flags`` - options such as not opening over a popup that is already open + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + if imgui.begin_popup("options"): + imgui.menu_item("reset vmin / vmax", "", False) + imgui.menu_item("reset gamma", "", False) + imgui.end_popup() + +begin_popup +^^^^^^^^^^^ + +.. imgui-signature:: begin_popup + +Call ``end_popup`` only when ``begin_popup`` returned ``True``. The popup closes when the user clicks outside it, or +when a menu item inside it is clicked. + +**Parameters** + +* ``str_id`` - the id that ``open_popup`` was called with + +.. imgui-example:: + :name: begin_popup + :interact: click 30 18 + + sigma = 1.4 + + if imgui.button("filter"): + imgui.open_popup("filter") + + if imgui.begin_popup("filter"): + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.end_popup() + +end_popup +^^^^^^^^^ + +.. imgui-signature:: end_popup + +Call it only when the matching ``begin_popup`` returned ``True``. + +**Parameters** + +none + +begin_popup_modal +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_modal + +A modal has a title bar and blocks everything behind it until it is closed. Passing ``p_open`` draws a close button in +its title bar. + +**Parameters** + +* ``name`` - the id that ``open_popup`` was called with, and the title +* ``p_open`` - when given, a close button is drawn and imgui closes the modal when it is clicked + +**Returns:** ``(open, p_open)`` + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("about"): + imgui.open_popup("About") + + if imgui.begin_popup_modal("About", True)[0]: + imgui.text("fastplotlib") + imgui.end_popup() + +close_current_popup +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: close_current_popup + +Closes the popup being drawn, for a control that should dismiss it. A menu item already does this on its own. + +**Parameters** + +none + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + if imgui.begin_popup("options"): + imgui.text("apply the filter to every frame?") + + if imgui.button("cancel"): + imgui.close_current_popup() + + imgui.end_popup() + +begin_popup_context_item +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_context_item + +Opens on a right-click on the element that precedes it, so a right-click menu needs no ``open_popup`` of its own. + +**Parameters** + +* ``str_id`` - identifies the popup, the preceding element is used when it is not given +* ``popup_flags`` - which mouse button opens it, right by default + +.. imgui-example:: + :interact: right_click 40 18 + + imgui.button("line-1") + + if imgui.begin_popup_context_item(): + imgui.menu_item("hide", "", False) + imgui.menu_item("delete", "", False) + imgui.end_popup() + +begin_popup_context_window +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_context_window + +Opens on a right-click anywhere in the window that is not over an element. + +**Parameters** + +* ``str_id`` - identifies the popup +* ``popup_flags`` - which mouse button opens it, right by default + +.. imgui-example:: + :width: 180 + :interact: right_click 120 40 + + imgui.text("right click the window") + + if imgui.begin_popup_context_window(): + imgui.menu_item("add line", "", False) + imgui.menu_item("add image", "", False) + imgui.end_popup() + +is_popup_open +^^^^^^^^^^^^^ + +.. imgui-signature:: is_popup_open + +**Parameters** + +* ``str_id`` - the id the popup was opened with +* ``flags`` - use ``imgui.PopupFlags_.any_popup_id`` to ask about any popup + +**Returns:** ``True`` while the popup is open + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + imgui.same_line() + imgui.text(f"open: {imgui.is_popup_open('options')}") + + if imgui.begin_popup("options"): + imgui.menu_item("reset", "", False) + imgui.end_popup() + +set_tooltip +^^^^^^^^^^^ + +.. imgui-signature:: set_tooltip + +**Parameters** + +* ``fmt`` - the text to draw in the tooltip + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button(fa.ICON_FA_MAXIMIZE) + + if imgui.is_item_hovered(): + imgui.set_tooltip("autoscale scene") + +set_item_tooltip +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_item_tooltip + +The same as ``set_tooltip`` behind an ``is_item_hovered`` check, for the common case of a tooltip on the element that +precedes it. + +**Parameters** + +* ``fmt`` - the text to draw in the tooltip + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button(fa.ICON_FA_ALIGN_CENTER) + imgui.set_item_tooltip("center scene") + +begin_tooltip +^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tooltip + +A tooltip that holds any elements, not only text. Call ``end_tooltip`` only when ``begin_tooltip`` returned ``True``. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_tooltip + :interact: hover 30 18 + + imgui.button("image-1") + + if imgui.is_item_hovered() and imgui.begin_tooltip(): + imgui.text("image-1") + imgui.separator() + imgui.label_text("shape", "(512, 512)") + imgui.label_text("dtype", "uint16") + imgui.end_tooltip() + +end_tooltip +^^^^^^^^^^^ + +.. imgui-signature:: end_tooltip + +Call it only when the matching ``begin_tooltip`` returned ``True``. + +**Parameters** + +none + +Layout +------ + +Elements are stacked vertically in the order they are called. These change where the next element goes, so most of them +draw nothing by themselves and are shown here between elements that do. + +same_line +^^^^^^^^^ + +.. imgui-signature:: same_line + +**Parameters** + +* ``offset_from_start_x`` - x position in window coordinates, the default continues after the previous element +* ``spacing`` - gap in pixels, the default uses the style spacing + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.button("reset") + +new_line +^^^^^^^^ + +.. imgui-signature:: new_line + +**Parameters** + +none + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.new_line() + imgui.button("reset") + +separator +^^^^^^^^^ + +.. imgui-signature:: separator + +**Parameters** + +none + +.. imgui-example:: + + imgui.text("filter") + imgui.separator() + imgui.text("export") + +spacing +^^^^^^^ + +.. imgui-signature:: spacing + +**Parameters** + +none + +.. imgui-example:: + + imgui.button("apply") + imgui.spacing() + imgui.spacing() + imgui.button("reset") + +dummy +^^^^^ + +.. imgui-signature:: dummy + +An empty element of a given size, to leave a gap that spacing cannot make. It takes no pointer input, unlike +``invisible_button``. + +**Parameters** + +* ``size`` - ``(width, height)`` of the gap + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.dummy((40, 0)) + imgui.same_line() + imgui.button("delete") + +indent +^^^^^^ + +.. imgui-signature:: indent + +**Parameters** + +* ``indent_w`` - width in pixels, the default uses the style indent + +.. imgui-example:: + :name: indent + + imgui.text("filter") + imgui.indent() + imgui.text("gaussian, sigma 1.4") + imgui.text("applied to every frame") + imgui.unindent() + imgui.text("export") + +unindent +^^^^^^^^ + +.. imgui-signature:: unindent + +**Parameters** + +* ``indent_w`` - width in pixels, the default uses the style indent + +begin_group +^^^^^^^^^^^ + +.. imgui-signature:: begin_group + +Everything between them becomes one item, so ``same_line`` places the whole group and ``is_item_hovered`` covers all of +it. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_group + + imgui.begin_group() + imgui.text("vmin") + imgui.text("12") + imgui.end_group() + + imgui.same_line() + imgui.dummy((20, 0)) + imgui.same_line() + + imgui.begin_group() + imgui.text("vmax") + imgui.text("208") + imgui.end_group() + +end_group +^^^^^^^^^ + +.. imgui-signature:: end_group + +Ends the group, and makes everything in it one item for ``same_line`` and the item queries. + +**Parameters** + +none + +align_text_to_frame_padding +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: align_text_to_frame_padding + +Text is drawn without a frame, so on a row shared with a slider or a button it sits too high. Call this before the text +to line them up. + +**Parameters** + +none + +.. imgui-example:: + + sigma = 1.4 + + imgui.align_text_to_frame_padding() + imgui.text("sigma") + imgui.same_line() + changed, sigma = imgui.slider_float("##sigma", v=sigma, v_min=0.1, v_max=10.0) + +set_next_item_width +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_item_width + +**Parameters** + +* ``item_width`` - width in pixels, a negative value leaves that many pixels between the element and the right edge + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + imgui.set_next_item_width(80) + changed, vmin = imgui.slider_float("vmin", v=vmin, v_min=0.0, v_max=255.0, format="%.0f") + + imgui.set_next_item_width(80) + changed, vmax = imgui.slider_float("vmax", v=vmax, v_min=0.0, v_max=255.0, format="%.0f") + +push_item_width +^^^^^^^^^^^^^^^ + +.. imgui-signature:: push_item_width + +The same as ``set_next_item_width`` but for every element until ``pop_item_width``. + +**Parameters** + +* ``item_width`` - width in pixels, a negative value leaves that many pixels between the element and the right edge + +.. imgui-example:: + :name: push_item_width + + vmin, vmax = 12.0, 208.0 + + imgui.push_item_width(80) + changed, vmin = imgui.slider_float("vmin", v=vmin, v_min=0.0, v_max=255.0, format="%.0f") + changed, vmax = imgui.slider_float("vmax", v=vmax, v_min=0.0, v_max=255.0, format="%.0f") + imgui.pop_item_width() + +pop_item_width +^^^^^^^^^^^^^^ + +.. imgui-signature:: pop_item_width + +Pops the width that ``push_item_width`` pushed. + +**Parameters** + +none + +calc_text_size +^^^^^^^^^^^^^^ + +.. imgui-signature:: calc_text_size + +**Parameters** + +* ``text`` - the text to measure +* ``text_end`` - measure up to this substring +* ``hide_text_after_double_hash`` - ignore everything after ``##``, as the elements do with their labels +* ``wrap_width`` - measure as if the text were wrapped at this width + +**Returns:** the size, use ``.x`` and ``.y`` + +.. imgui-example:: + + label = "vmin / vmax" + size = imgui.calc_text_size(label) + + imgui.text(label) + imgui.text(f"that text is {size.x:.0f} x {size.y:.0f} px") + +get_content_region_avail +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_content_region_avail + +The space left in the window from the current position, which is how an element is sized to fill the window. + +**Parameters** + +none + +**Returns:** the available size, use ``.x`` and ``.y`` + +.. imgui-example:: + :width: 200 + + available = imgui.get_content_region_avail() + + imgui.text(f"{available.x:.0f} x {available.y:.0f} px left") + imgui.button("fill the width", (available.x, 0)) + +get_cursor_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: get_cursor_pos + +Where the next element goes, in window coordinates. + +**Parameters** + +* ``local_pos`` - ``(x, y)`` in window coordinates + +.. imgui-example:: + :name: set_cursor_pos + + imgui.set_cursor_pos((60, 30)) + imgui.button("moved") + +set_cursor_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: set_cursor_pos + +Moves the position of the next element, in window coordinates. + +**Parameters** + +* ``local_pos`` - ``(x, y)`` in window coordinates + +.. imgui-example:: + + imgui.set_cursor_pos((60, 30)) + imgui.button("moved") + +get_cursor_screen_pos +^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_cursor_screen_pos + +The same position in canvas coordinates, which is what a draw list takes. + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates + +.. imgui-example:: + :name: get_cursor_screen_pos + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + draw_list.add_rect_filled( + position, + (position.x + 60, position.y + 20), + imgui.color_convert_float4_to_u32((0.2, 0.6, 0.95, 1.0)), + ) + imgui.dummy((60, 20)) + +set_cursor_screen_pos +^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_cursor_screen_pos + +Moves the position of the next element, in canvas coordinates. + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates + +get_text_line_height +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_text_line_height + +The height of a line of text, and the height of an element that has a frame such as a button or a slider. Use them to +size something you draw yourself so that it lines up with the elements around it. + +**Parameters** + +none + +.. imgui-example:: + :name: get_frame_height + + imgui.text(f"text line: {imgui.get_text_line_height():.0f} px") + imgui.text(f"framed element: {imgui.get_frame_height():.0f} px") + +get_frame_height +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_frame_height + +The height of an element that has a frame, such as a button or a slider. + +**Parameters** + +none + +**Returns:** the height in pixels + +.. imgui-example:: + + imgui.text(f"framed element: {imgui.get_frame_height():.0f} px") + +Windows +------- + +In fastplotlib the window is created for you, ``ImguiWindow.update()`` draws into it. These are for a window you create +yourself, inside an overridden ``ImguiWindow.draw()``. + +begin +^^^^^ + +.. imgui-signature:: begin + +``end`` is called whether or not ``begin`` returned ``True``. ``begin`` returns ``False`` when the window is collapsed, +in which case its contents can be skipped. + +**Parameters** + +* ``name`` - the title, and the id of the window, ``"title##id"`` separates the two +* ``p_open`` - when given, a close button is drawn in the title bar and this is set to ``False`` when it is clicked + +**Returns:** ``(expanded, p_open)`` + +.. imgui-example:: + :window: none + :size: 240, 120 + + expanded, open_ = imgui.begin("filter", True) + + if expanded: + imgui.text("gaussian") + + imgui.end() + +end +^^^ + +.. imgui-signature:: end + +Called whether or not ``begin`` returned ``True``. + +**Parameters** + +none + +begin_child +^^^^^^^^^^^ + +.. imgui-signature:: begin_child + +A region within a window, with its own scrolling and clipping. Use it for a list that should scroll on its own. + +**Parameters** + +* ``str_id``, ``id_`` - identifies the region +* ``size`` - ``(width, height)``, a zero component fills the available space, a negative one leaves that many pixels + +.. imgui-example:: + :name: begin_child + + if imgui.begin_child("graphics", (160, 80), child_flags=imgui.ChildFlags_.borders): + for i in range(8): + imgui.text(f"line-{i}") + + imgui.end_child() + +end_child +^^^^^^^^^ + +.. imgui-signature:: end_child + +Call it only when the matching ``begin_child`` returned ``True``. + +**Parameters** + +none + +set_next_window_pos +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_pos + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates +* ``cond`` - an ``imgui.Cond_`` value, e.g. ``appearing`` to place it only when it first appears so the user can move it +* ``pivot`` - which point of the window lands on ``pos``, ``(0.5, 0.5)`` centers it there + +.. imgui-example:: + :window: none + :size: 260, 130 + + imgui.set_next_window_pos((40, 30)) + imgui.set_next_window_size((160, 60)) + imgui.begin("filter") + imgui.text("placed at 40, 30") + imgui.end() + +set_next_window_size +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_size + +**Parameters** + +* ``size`` - ``(width, height)``, a zero component makes that axis fit its contents +* ``cond`` - an ``imgui.Cond_`` value + +.. imgui-example:: + :window: none + :size: 240, 120 + + imgui.set_next_window_size((150, 0)) + imgui.begin("filter") + imgui.text("fixed width, auto height") + imgui.end() + +set_next_window_collapsed +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_collapsed + +**Parameters** + +* ``collapsed`` - the state to set +* ``cond`` - an ``imgui.Cond_`` value + +.. imgui-example:: + :window: none + :size: 240, 90 + + imgui.set_next_window_collapsed(True) + imgui.begin("filter") + imgui.text("not drawn while collapsed") + imgui.end() + +get_window_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_pos + +The position and size of the window being drawn. For laying out contents, ``get_content_region_avail`` is what you +want, since it accounts for padding and for the position within the window. + +**Parameters** + +none + +.. imgui-example:: + :name: get_window_size + :width: 200 + + size = imgui.get_window_size() + + imgui.text(f"window: {size.x:.0f} x {size.y:.0f} px") + +get_window_size +^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_size + +**Parameters** + +none + +**Returns:** the size, use ``.x`` and ``.y`` + +.. imgui-example:: + :width: 200 + + size = imgui.get_window_size() + + imgui.text(f"window: {size.x:.0f} x {size.y:.0f} px") + +get_window_width +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_width + +**Parameters** + +none + +**Returns:** the width in pixels + +get_window_height +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_height + +**Parameters** + +none + +**Returns:** the height in pixels + +get_window_draw_list +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_draw_list + +The draw list of the window, for drawing shapes and text yourself. Positions are in canvas coordinates, so they start +from ``get_cursor_screen_pos``. + +**Parameters** + +none + +**Returns:** an ``imgui.ImDrawList`` + +.. imgui-example:: + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + blue = imgui.color_convert_float4_to_u32((0.2, 0.6, 0.95, 1.0)) + + draw_list.add_rect_filled(position, (position.x + 120, position.y + 8), blue) + draw_list.add_circle_filled((position.x + 30, position.y + 30), 8, white) + draw_list.add_text((position.x + 50, position.y + 22), white, "drawn by hand") + + imgui.dummy((120, 45)) + +set_scroll_here_y +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_scroll_here_y + +``set_scroll_here_y`` scrolls to the element that was just drawn, which is how a list follows a selection. + +**Parameters** + +* ``center_y_ratio`` - where the element ends up, ``0.0`` top, ``0.5`` center, ``1.0`` bottom +* ``scroll_y`` - the scroll amount in pixels + +.. imgui-example:: + :name: set_scroll_here_y + + if imgui.begin_child("graphics", (160, 70), child_flags=imgui.ChildFlags_.borders): + for i in range(10): + imgui.text(f"line-{i}") + + if i == 6: + imgui.set_scroll_here_y(0.5) + + imgui.end_child() + +get_scroll_y +^^^^^^^^^^^^ + +.. imgui-signature:: get_scroll_y + +**Parameters** + +none + +**Returns:** the scroll amount in pixels + +set_scroll_y +^^^^^^^^^^^^ + +.. imgui-signature:: set_scroll_y + +**Parameters** + +* ``scroll_y`` - the scroll amount in pixels + +Style and ids +------------- + +Every push has a matching pop. A push that is not popped leaks into everything drawn afterwards, including elements +that fastplotlib draws. + +push_id +^^^^^^^ + +.. imgui-signature:: push_id + +imgui identifies an element by its label, so two elements with the same label are the same element and share their +state. Push an id around them to keep them apart, which is what a loop over graphics needs. + +**Parameters** + +* ``str_id``, ``int_id``, ``ptr_id`` - the value to push, it is hashed and is not drawn +* ``str_id_begin``, ``str_id_end`` - a substring to push + +.. imgui-example:: + :name: push_id + + thickness = {"line-1": 4.0, "line-2": 9.0} + + for name in thickness: + imgui.push_id(name) + + imgui.text(name) + imgui.same_line() + changed, thickness[name] = imgui.slider_float("##thickness", v=thickness[name], v_min=1.0, v_max=20.0) + + imgui.pop_id() + +pop_id +^^^^^^ + +.. imgui-signature:: pop_id + +Pops the id that ``push_id`` pushed. + +**Parameters** + +none + +push_style_color +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: push_style_color + +**Parameters** + +* ``idx`` - which color, an ``imgui.Col_`` value +* ``col`` - the color, ``(r, g, b, a)`` or a packed ``int`` +* ``count`` - how many pushes to pop + +.. imgui-example:: + :name: push_style_color + + imgui.push_style_color(imgui.Col_.button, (0.6, 0.15, 0.15, 1.0)) + imgui.push_style_color(imgui.Col_.button_hovered, (0.75, 0.2, 0.2, 1.0)) + + imgui.button("delete graphic") + + imgui.pop_style_color(2) + + imgui.button("keep graphic") + +pop_style_color +^^^^^^^^^^^^^^^ + +.. imgui-signature:: pop_style_color + +**Parameters** + +* ``count`` - how many pushed colors to pop + +push_style_var +^^^^^^^^^^^^^^ + +.. imgui-signature:: push_style_var + +**Parameters** + +* ``idx`` - which variable, an ``imgui.StyleVar_`` value +* ``val`` - a float, or ``(x, y)`` for the variables that are a pair +* ``count`` - how many pushes to pop + +.. imgui-example:: + :name: push_style_var + + imgui.push_style_var(imgui.StyleVar_.frame_rounding, 10.0) + imgui.button("rounded") + imgui.pop_style_var() + + imgui.button("default") + +pop_style_var +^^^^^^^^^^^^^ + +.. imgui-signature:: pop_style_var + +**Parameters** + +* ``count`` - how many pushed variables to pop + +get_style_color_vec4 +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_style_color_vec4 + +**Parameters** + +* ``idx`` - which color, an ``imgui.Col_`` value + +**Returns:** the color, use ``.x``, ``.y``, ``.z``, ``.w`` for r, g, b, a + +.. imgui-example:: + + color = imgui.get_style_color_vec4(imgui.Col_.text) + + imgui.text(f"text color: {color.x:.2f}, {color.y:.2f}, {color.z:.2f}") + +get_color_u32 +^^^^^^^^^^^^^ + +.. imgui-signature:: get_color_u32 + +A draw list takes a packed 32-bit color, not a tuple. ``get_color_u32`` packs a style color or your own color and +applies the global style alpha, ``color_convert_float4_to_u32`` packs a color as it is. + +**Parameters** + +* ``idx`` - which style color, an ``imgui.Col_`` value +* ``col`` - a color, ``(r, g, b, a)`` or a packed ``int`` +* ``alpha_mul`` - multiplies the alpha +* ``in_`` - the color to pack, ``(r, g, b, a)`` + +**Returns:** the packed color + +.. imgui-example:: + :name: get_color_u32 + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + draw_list.add_rect_filled( + position, (position.x + 60, position.y + 20), imgui.get_color_u32(imgui.Col_.button) + ) + draw_list.add_rect_filled( + (position.x + 70, position.y), + (position.x + 130, position.y + 20), + imgui.color_convert_float4_to_u32((1.0, 0.8, 0.2, 1.0)), + ) + + imgui.dummy((130, 20)) + +color_convert_float4_to_u32 +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: color_convert_float4_to_u32 + +Packs a color as it is, without applying the style alpha. + +**Parameters** + +* ``in_`` - the color to pack, ``(r, g, b, a)`` + +**Returns:** the packed color + +get_font_size +^^^^^^^^^^^^^ + +.. imgui-signature:: get_font_size + +**Parameters** + +none + +**Returns:** the height of the font in pixels + +.. imgui-example:: + + imgui.text(f"font size: {imgui.get_font_size():.0f} px") + +begin_disabled +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_disabled + +Everything between them is greyed out and takes no input, for a control that does not apply yet. + +**Parameters** + +* ``disabled`` - pass ``False`` to leave the elements enabled, so the call can be made unconditionally + +.. imgui-example:: + :name: begin_disabled + + apply_filter, sigma = False, 1.4 + + changed, apply_filter = imgui.checkbox("gaussian filter", apply_filter) + + imgui.begin_disabled(not apply_filter) + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.end_disabled() + +end_disabled +^^^^^^^^^^^^ + +.. imgui-signature:: end_disabled + +Ends the block that ``begin_disabled`` started. + +**Parameters** + +none + +Queries +------- + +These ask about the element that was drawn last, about the window, or about the mouse and keyboard. The item queries +refer to the element immediately above them, so they go straight after the element they ask about. + +The examples below print what they return, and the images were captured with the pointer over the element or a button +held down, which is why they read ``True``. + +is_item_hovered +^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_hovered + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button("autoscale") + imgui.text(f"hovered: {imgui.is_item_hovered()}") + +is_item_active +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_active + +.. imgui-example:: + :interact: press 30 18 + + imgui.button("autoscale") + imgui.text(f"active: {imgui.is_item_active()}") + +is_item_clicked +^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_clicked + +**Parameters** + +* ``mouse_button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: press 30 18 + + imgui.button("autoscale") + imgui.text(f"clicked: {imgui.is_item_clicked()}") + +is_item_edited +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_edited + +``is_item_deactivated_after_edit`` is the one to use for work that is too expensive to run while a slider is being +dragged, since it is ``True`` only on the frame the drag ends. + +.. imgui-example:: + :name: is_item_deactivated_after_edit + :interact: drag 60 18 100 18 + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + + imgui.text(f"edited: {imgui.is_item_edited()}") + imgui.text(f"activated: {imgui.is_item_activated()}") + imgui.text(f"finished: {imgui.is_item_deactivated_after_edit()}") + +is_item_activated +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_activated + +``True`` on the frame the element became active, e.g. the frame a drag started. + +**Parameters** + +none + +.. imgui-example:: + :interact: press 60 18 + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.text(f"activated: {imgui.is_item_activated()}") + +is_item_deactivated_after_edit +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_deactivated_after_edit + +``True`` only on the frame an edit ends, which is what to use for work that is too expensive to run while a +slider is being dragged. + +**Parameters** + +none + +.. imgui-example:: + :interact: drag 60 18 100 18; release + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.text(f"finished: {imgui.is_item_deactivated_after_edit()}") + +is_any_item_hovered +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_any_item_hovered + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button("autoscale") + imgui.button("center") + + imgui.text(f"any hovered: {imgui.is_any_item_hovered()}") + +is_window_hovered +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_hovered + +.. imgui-example:: + :interact: hover 60 40 + + imgui.text(f"window hovered: {imgui.is_window_hovered()}") + +is_window_focused +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_focused + +.. imgui-example:: + :interact: click 60 40 + + imgui.text(f"window focused: {imgui.is_window_focused()}") + +is_window_appearing +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_appearing + +``True`` on the first frame the window is drawn, for setup that should happen once, such as sizing a table column. + +**Parameters** + +none + +.. imgui-example:: + + imgui.text(f"appearing: {imgui.is_window_appearing()}") + +is_mouse_down +^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_down + +These ask about the mouse anywhere, not about an element. A right-click that should open something belongs in +``begin_popup_context_item`` instead. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``repeat`` - report repeats while the button is held + +.. imgui-example:: + :name: is_mouse_down + :interact: press 60 40 + + imgui.text(f"left down: {imgui.is_mouse_down(0)}") + imgui.text(f"left clicked: {imgui.is_mouse_clicked(0)}") + imgui.text(f"right down: {imgui.is_mouse_down(1)}") + +is_mouse_clicked +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_clicked + +``True`` on the frame the button goes down. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``repeat`` - report repeats while the button is held + +.. imgui-example:: + :interact: press 60 30 + + imgui.text(f"left clicked: {imgui.is_mouse_clicked(0)}") + +is_mouse_released +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_released + +``True`` on the frame the button goes up. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: click 60 30 + + imgui.text(f"left released: {imgui.is_mouse_released(0)}") + +is_mouse_double_clicked +^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_double_clicked + +``True`` on the frame of the second click of a double click. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: double_click 60 30 + + imgui.text(f"double clicked: {imgui.is_mouse_double_clicked(0)}") + +is_mouse_dragging +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_dragging + +The delta is measured from where the button went down. Reset it each frame to get the movement since the last frame, +which is what a drag handle needs. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``lock_threshold`` - how far the pointer must move before it counts as a drag, the default uses the style threshold + +.. imgui-example:: + :name: is_mouse_dragging + :interact: drag 40 30 90 45 + + delta = imgui.get_mouse_drag_delta(0) + + imgui.text(f"dragging: {imgui.is_mouse_dragging(0)}") + imgui.text(f"delta: {delta.x:.0f}, {delta.y:.0f}") + +get_mouse_drag_delta +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_mouse_drag_delta + +The movement since the button went down, in pixels. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``lock_threshold`` - how far the pointer must move before it counts as a drag + +**Returns:** the delta, use ``.x`` and ``.y`` + +.. imgui-example:: + :interact: drag 40 30 90 45 + + delta = imgui.get_mouse_drag_delta(0) + + imgui.text(f"delta: {delta.x:.0f}, {delta.y:.0f}") + +reset_mouse_drag_delta +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: reset_mouse_drag_delta + +Sets the delta back to zero, call it each frame to get the movement since the last frame rather than since the +button went down. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +get_mouse_pos +^^^^^^^^^^^^^ + +.. imgui-signature:: get_mouse_pos + +**Parameters** + +none + +**Returns:** the pointer position in canvas coordinates, use ``.x`` and ``.y`` + +.. imgui-example:: + :interact: hover 70 30 + + position = imgui.get_mouse_pos() + + imgui.text(f"pointer: {position.x:.0f}, {position.y:.0f}") + +is_key_pressed +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_key_pressed + +**Parameters** + +* ``key`` - an ``imgui.Key`` member, e.g. ``imgui.Key.right_arrow`` +* ``repeat`` - report repeats while the key is held + +.. imgui-example:: + :name: is_key_pressed + :interact: hover 60 30; key right_arrow + + index = 42 + + if imgui.is_key_pressed(imgui.Key.right_arrow): + index += 1 + + if imgui.is_key_pressed(imgui.Key.left_arrow): + index -= 1 + + imgui.text(f"index: {index}") + +is_key_down +^^^^^^^^^^^ + +.. imgui-signature:: is_key_down + +``True`` while the key is held, rather than only on the frame it goes down. + +**Parameters** + +* ``key`` - an ``imgui.Key`` member + +.. imgui-example:: + :interact: hover 60 30; key left_shift + + imgui.text(f"shift held: {imgui.is_key_down(imgui.Key.left_shift)}") + +get_io +^^^^^^ + +.. imgui-signature:: get_io + +The imgui io structure. ``want_capture_mouse`` is the field to know about: it is ``True`` while imgui is using the +pointer, and fastplotlib relies on it to keep clicks on a UI from reaching the plot. + +**Parameters** + +none + +**Returns:** an ``imgui.IO`` + +.. imgui-example:: + :interact: hover 60 30 + + io = imgui.get_io() + + imgui.text(f"framerate: {io.framerate:.0f}") + imgui.text(f"capture mouse: {io.want_capture_mouse}") + +Plots +----- + +These draw a small line plot or histogram from an array of values, for a preview next to the controls. They are not a +plotting library, a fastplotlib subplot is. + +``values`` must be a contiguous ``float32`` array. + +plot_lines +^^^^^^^^^^ + +.. imgui-signature:: plot_lines + +**Parameters** + +* ``label`` - drawn to the right of the plot, ``"##hidden"`` suppresses it +* ``values`` - the values to plot +* ``values_offset`` - index to start from, for a ring buffer +* ``overlay_text`` - text drawn over the plot +* ``scale_min``, ``scale_max`` - the y range, the default fits the values +* ``graph_size`` - ``(width, height)``, a zero component is a default size +* ``stride`` - byte stride between values, for a column of a 2d array + +.. imgui-example:: + + values = np.sin(np.linspace(0, 4 * np.pi, 100)).astype(np.float32) + + imgui.plot_lines("##trace", values, graph_size=(220, 60), overlay_text="channel 0") + +plot_histogram +^^^^^^^^^^^^^^ + +.. imgui-signature:: plot_histogram + +**Parameters** + +* ``label`` - drawn to the right of the plot +* ``values`` - the bin counts +* ``values_offset`` - index to start from +* ``overlay_text`` - text drawn over the plot +* ``scale_min``, ``scale_max`` - the y range, the default fits the values +* ``graph_size`` - ``(width, height)``, a zero component is a default size +* ``stride`` - byte stride between values + +.. imgui-example:: + + data = np.random.normal(loc=120, scale=30, size=100_000) + counts = np.histogram(data, bins=64)[0].astype(np.float32) + + imgui.plot_histogram("##histogram", counts, graph_size=(220, 60)) + +image +^^^^^ + +.. imgui-signature:: image + +Draws a texture that you have uploaded to the GPU and registered with the imgui renderer, which is how +``ImguiColorbar`` draws its colormap bar. There is no example here because the texture has to come from the wgpu +device of the Figure:: + + texture_ref = figure.imgui_renderer.backend.register_texture(texture.create_view()) + imgui.image(texture_ref, (24, 200)) + +**Parameters** + +* ``tex_ref`` - an ``imgui.ImTextureRef`` from ``register_texture`` +* ``image_size`` - ``(width, height)`` to draw it at +* ``uv0``, ``uv1`` - the region of the texture to draw, ``(0, 0)`` to ``(1, 1)`` by default + +image_button +^^^^^^^^^^^^ + +.. imgui-signature:: image_button + +``image`` that responds to a click. + +**Parameters** + +* ``str_id`` - identifies the button +* ``tex_ref`` - an ``imgui.ImTextureRef`` from ``register_texture`` +* ``image_size`` - ``(width, height)`` to draw it at +* ``uv0``, ``uv1`` - the region of the texture to draw +* ``bg_col``, ``tint_col`` - background drawn behind the image, and a color the image is multiplied by + +**Returns:** ``True`` on the frame the button is clicked + +Tables +------ + +A table is opened with ``begin_table``, and ``end_table`` is called only when it returned ``True``. Cells are filled by +walking rows and columns, either with ``table_next_column`` or by setting the column index. + +begin_table +^^^^^^^^^^^ + +.. imgui-signature:: begin_table + +**Parameters** + +* ``str_id`` - identifies the table +* ``columns`` - how many columns +* ``outer_size`` - ``(width, height)`` of the table, a zero height fits the rows +* ``inner_width`` - width of the scrolling region when the table scrolls horizontally + +.. imgui-example:: + :name: begin_table + + graphics = [("line-1", "LineGraphic", True), ("image-1", "ImageGraphic", False)] + + if imgui.begin_table("graphics", 3, flags=imgui.TableFlags_.borders): + for name, kind, visible in graphics: + imgui.table_next_row() + + imgui.table_next_column() + imgui.text(name) + + imgui.table_next_column() + imgui.text(kind) + + imgui.table_next_column() + imgui.text("visible" if visible else "hidden") + + imgui.end_table() + +end_table +^^^^^^^^^ + +.. imgui-signature:: end_table + +Call it only when the matching ``begin_table`` returned ``True``. + +**Parameters** + +none + +table_next_row +^^^^^^^^^^^^^^ + +.. imgui-signature:: table_next_row + +**Parameters** + +* ``min_row_height`` - minimum height of the row in pixels + +.. imgui-example:: + + if imgui.begin_table("frames", 2, flags=imgui.TableFlags_.borders): + for index in range(3): + imgui.table_next_row(min_row_height=24) + + imgui.table_next_column() + imgui.text(f"frame {index}") + + imgui.table_next_column() + imgui.text(f"{index * 40} ms") + + imgui.end_table() + +table_next_column +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_next_column + +``table_next_column`` moves to the next cell, wrapping to the first column of the next row. Use +``table_set_column_index`` to fill cells out of order. + +**Parameters** + +* ``column_n`` - the column to move to + +**Returns:** ``True`` when the column is visible, a clipped or hidden column can be skipped + +.. imgui-example:: + :name: table_set_column_index + + if imgui.begin_table("stats", 2, flags=imgui.TableFlags_.borders): + for label, value in [("vmin", "12"), ("vmax", "208")]: + imgui.table_next_row() + + imgui.table_set_column_index(0) + imgui.text(label) + + imgui.table_set_column_index(1) + imgui.text(value) + + imgui.end_table() + +table_set_column_index +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_set_column_index + +Fills a cell out of order, rather than moving to the next one. + +**Parameters** + +* ``column_n`` - the column to move to + +**Returns:** ``True`` when the column is visible + +table_setup_column +^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_setup_column + +Declare the columns before any row, then ``table_headers_row`` draws one row with their labels. + +**Parameters** + +* ``label`` - the column header +* ``init_width_or_weight`` - a starting width in pixels, or a share of the table width for a stretched column. + imgui rejects it unless the sizing policy is explicit, so pass ``imgui.TableColumnFlags_.width_fixed`` or + ``width_stretch`` with it +* ``user_id`` - an id you can read back when sorting + +.. imgui-example:: + :name: table_headers_row + + if imgui.begin_table("graphics", 2, flags=imgui.TableFlags_.borders): + imgui.table_setup_column("name", flags=imgui.TableColumnFlags_.width_fixed, init_width_or_weight=90) + imgui.table_setup_column("type") + imgui.table_headers_row() + + for name, kind in [("line-1", "LineGraphic"), ("image-1", "ImageGraphic")]: + imgui.table_next_row() + + imgui.table_next_column() + imgui.text(name) + + imgui.table_next_column() + imgui.text(kind) + + imgui.end_table() + +table_headers_row +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_headers_row + +Draws one row of headers from the labels given to ``table_setup_column``. + +**Parameters** + +none diff --git a/docs/source/imgui/reference/flags.rst b/docs/source/imgui/reference/flags.rst new file mode 100644 index 000000000..dffd47bb0 --- /dev/null +++ b/docs/source/imgui/reference/flags.rst @@ -0,0 +1,154 @@ +Flags +===== + +Flags are passed as ``int``. The values are ``enum.IntFlag`` members of the classes below and can be +combined with ``|``:: + + imgui.slider_float( + "gamma", v=gamma, v_min=0.1, v_max=5.0, + flags=imgui.SliderFlags_.logarithmic | imgui.SliderFlags_.no_input, + ) + +``Col_``, ``Cond_``, ``StyleVar_`` hold single values rather than flags, they are listed here because the +elements take them. + +.. _imgui.ButtonFlags_: + +imgui.ButtonFlags\_ +------------------- + +.. imgui-flags:: ButtonFlags_ + +.. _imgui.ChildFlags_: + +imgui.ChildFlags\_ +------------------ + +.. imgui-flags:: ChildFlags_ + +.. _imgui.Col_: + +imgui.Col\_ +----------- + +.. imgui-flags:: Col_ + +.. _imgui.ColorEditFlags_: + +imgui.ColorEditFlags\_ +---------------------- + +.. imgui-flags:: ColorEditFlags_ + +.. _imgui.ComboFlags_: + +imgui.ComboFlags\_ +------------------ + +.. imgui-flags:: ComboFlags_ + +.. _imgui.Cond_: + +imgui.Cond\_ +------------ + +.. imgui-flags:: Cond_ + +.. _imgui.FocusedFlags_: + +imgui.FocusedFlags\_ +-------------------- + +.. imgui-flags:: FocusedFlags_ + +.. _imgui.HoveredFlags_: + +imgui.HoveredFlags\_ +-------------------- + +.. imgui-flags:: HoveredFlags_ + +.. _imgui.InputTextFlags_: + +imgui.InputTextFlags\_ +---------------------- + +.. imgui-flags:: InputTextFlags_ + +.. _imgui.PopupFlags_: + +imgui.PopupFlags\_ +------------------ + +.. imgui-flags:: PopupFlags_ + +.. _imgui.SelectableFlags_: + +imgui.SelectableFlags\_ +----------------------- + +.. imgui-flags:: SelectableFlags_ + +.. _imgui.SliderFlags_: + +imgui.SliderFlags\_ +------------------- + +.. imgui-flags:: SliderFlags_ + +.. _imgui.StyleVar_: + +imgui.StyleVar\_ +---------------- + +.. imgui-flags:: StyleVar_ + +.. _imgui.TabBarFlags_: + +imgui.TabBarFlags\_ +------------------- + +.. imgui-flags:: TabBarFlags_ + +.. _imgui.TabItemFlags_: + +imgui.TabItemFlags\_ +-------------------- + +.. imgui-flags:: TabItemFlags_ + +.. _imgui.TableColumnFlags_: + +imgui.TableColumnFlags\_ +------------------------ + +.. imgui-flags:: TableColumnFlags_ + +.. _imgui.TableFlags_: + +imgui.TableFlags\_ +------------------ + +.. imgui-flags:: TableFlags_ + +.. _imgui.TableRowFlags_: + +imgui.TableRowFlags\_ +--------------------- + +.. imgui-flags:: TableRowFlags_ + +.. _imgui.TreeNodeFlags_: + +imgui.TreeNodeFlags\_ +--------------------- + +.. imgui-flags:: TreeNodeFlags_ + +.. _imgui.WindowFlags_: + +imgui.WindowFlags\_ +------------------- + +.. imgui-flags:: WindowFlags_ + diff --git a/docs/source/imgui/reference/index.rst b/docs/source/imgui/reference/index.rst new file mode 100644 index 000000000..981a247dd --- /dev/null +++ b/docs/source/imgui/reference/index.rst @@ -0,0 +1,8 @@ +imgui reference +*************** + +.. toctree:: + :maxdepth: 3 + + elements + flags diff --git a/docs/source/index.rst b/docs/source/index.rst index c44f4e3a8..68c28a577 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,6 +6,17 @@ Welcome to fastplotlib's documentation! :maxdepth: 2 user_guide/index + +.. toctree:: + :caption: imgui + :maxdepth: 2 + + imgui/index + +.. toctree:: + :caption: Developer notes + :maxdepth: 2 + developer_notes/index .. toctree:: diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 0342807e1..c55c71722 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -471,6 +471,17 @@ cmap | value | str | new cmap name | +----------+------+---------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ @@ -619,6 +630,17 @@ data | value | np.ndarray | float | new data values | +----------+--------------------------------------+--------------------------------------------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ @@ -767,6 +789,17 @@ cmap | value | str | new cmap name | +----------+------+---------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 5b6bbc7d5..c857ebb9c 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -562,23 +562,16 @@ are no callbacks, but it is easy to learn if you see a few examples. .. image:: ../_static/guide_imgui.png We specifically use `imgui-bundle `_ for the python bindings in fastplotlib. -There is large community and many resources out there on building UIs using imgui. To install ``fastplotlib`` with ``imgui`` use the ``imgui`` extras option, i.e. ``pip install fastplotlib[imgui]``, or ``pip install imgui_bundle`` if you've already installed fastplotlib. Fastplotlib comes built-in with imgui UIs for subplot toolbars and a standard right-click menu with a number of options. -You can also make custom GUIs and embed them within the canvas, see the examples gallery for detailed examples. +The standard right-click menu can be extended or replaced, and a right-click popup can also be set on a ``Subplot`` or +a ``Graphic``. You can also make custom GUIs and embed them within the canvas. -**Some tips:** - -The ``imgui-bundle`` docs as of March 2025 don't have a nice API list (as far as I know), here is how we go about developing UIs with imgui: - -1. Use the ``pyimgui`` API docs to locate the type of UI element we want, for example if we want a ``slider_int``: https://pyimgui.readthedocs.io/en/latest/reference/imgui.core.html#imgui.core.slider_int - -2. Look at the function signature in the ``imgui-bundle`` sources. You can usually access this easily with your IDE: https://github.com/pthom/imgui_bundle/blob/a5e7d46555832c40e9be277d4747eac5a303dbfc/bindings/imgui_bundle/imgui/__init__.pyi#L1693-L1696 - -3. ``pyimgui`` and ``imgui-bundle`` sometimes don't have the same function signature, so we use a combination of the pyimgui docs and -imgui-bundle function signature to understand and implement the UI element. +The :doc:`imgui guide ` covers adding UIs to a Figure, and the +:doc:`imgui element reference ` documents every element with its signature, its arguments, and +an image of what it draws. ImageWidget ----------- diff --git a/examples/guis/imgui_append.py b/examples/guis/imgui_append.py new file mode 100644 index 000000000..cd8b0e958 --- /dev/null +++ b/examples/guis/imgui_append.py @@ -0,0 +1,49 @@ +""" +ImGUI append to windows +======================= + +You can append imgui elements to an existing window, including the subplot toolbar. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui, icons_fontawesome_6 as fa + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_line(np.random.rand(100), colors="r", name="line") + + +# create an edge window +@figure.add_imgui_window(location="right", size=200, title="controls") +def gui(fig): + if imgui.button("randomize"): + fig[0, 0]["line"].data[:, 1] = np.random.rand(100) + + +# append more elements to the same window +@figure.append_imgui_window(location="right") +def more(fig): + line = fig[0, 0]["line"] + _, line.thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + + +# append a button to the subplot toolbar that toggles axes visibility +@figure[0, 0].append_imgui_window(location="toolbar") +def toolbar_extra(subplot): + imgui.same_line() + _, subplot.axes.visible = imgui.checkbox(fa.ICON_FA_RULER_COMBINED, subplot.axes.visible) + if imgui.is_item_hovered(0): + imgui.set_tooltip("Axes visibility") + + +figure.show(maintain_aspect=False) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py index 7f42eadd6..11af54eac 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -13,8 +13,8 @@ import numpy as np import fastplotlib as fpl -# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +# subclass from ImguiWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui # make some initial data @@ -35,12 +35,9 @@ figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave") -class ImguiExample(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title) - # this UI will modify the line - self._line = self._figure[0, 0]["sine-wave"] - +class ImguiExample(ImguiWindow): + def __init__(self): + super().__init__() # set the default values # wave amplitude self._amplitude = 1 @@ -104,15 +101,10 @@ def _set_data(self): # make GUI instance -gui = ImguiExample( - figure, # the figure this GUI instance should live inside - size=275, # width or height of the GUI window within the figure - location="right", # the edge to place this window at - title="Imgui Window", # window title -) - -# add it to the figure -figure.add_gui(gui) +gui = ImguiExample() + +# add it to the right edge of the figure, 275px wide +figure.add_imgui_window(gui, location="right", size=275, title="Imgui Window") figure.show() diff --git a/examples/guis/imgui_colorbar.py b/examples/guis/imgui_colorbar.py new file mode 100644 index 000000000..947bd5735 --- /dev/null +++ b/examples/guis/imgui_colorbar.py @@ -0,0 +1,51 @@ +""" +ImGUI Colorbar +============== + +Create an ImguiColorbar manually and add it to the right edge of each subplot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio +from fastplotlib.ui import ImguiColorbar + +# a grayscale image and an RGB image +camera = iio.imread("imageio:camera.png") +astronaut = iio.imread("imageio:astronaut.png") + +figure = fpl.Figure(shape=(2, 2), size=(900, 900), canvas_kwargs={"max_fps": 999, "vsync": False}) + +# top row: a plain colorbar for each image +# grayscale image displayed with a colormap +camera_image = figure[0, 0].add_image(camera, cmap="viridis", name="camera") +figure[0, 0].add_imgui_window(ImguiColorbar(images=camera_image), location="right", size=80) + +# RGB image, it has no colormap so its colorbar is drawn with "gray" +astronaut_image = figure[0, 1].add_image(astronaut, name="astronaut") +figure[0, 1].add_imgui_window(ImguiColorbar(images=astronaut_image), location="right", size=80) + +# bottom row: the same images, but with a precomputed 100-bin histogram on the colorbar +camera_image2 = figure[1, 0].add_image(camera, cmap="viridis", name="camera") +camera_histogram = np.histogram(camera, bins=100) +figure[1, 0].add_imgui_window( + ImguiColorbar(images=camera_image2, histogram=camera_histogram), location="right", size=100 +) + +astronaut_image2 = figure[1, 1].add_image(astronaut, name="astronaut") +astronaut_histogram = np.histogram(astronaut, bins=100) +figure[1, 1].add_imgui_window( + ImguiColorbar(images=astronaut_image2, histogram=astronaut_histogram), location="right", size=100 +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_decorator.py b/examples/guis/imgui_decorator.py new file mode 100644 index 000000000..e08f7a926 --- /dev/null +++ b/examples/guis/imgui_decorator.py @@ -0,0 +1,43 @@ +""" +ImGUI decorator +=============== + +You can quickly create imgui UIs using a decorator. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui + +np.random.seed(0) +xs = np.linspace(0, 2 * np.pi, 100) + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_line(np.column_stack([xs, np.sin(xs)]), thickness=3, name="sine") + + +# the decorated function draws the imgui elements +# it optionally takes the figure as its only argument +@figure.add_imgui_window(location="right", size=200, title="controls") +def gui(fig): + line = fig[0, 0]["sine"] + + changed, thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + if changed: + line.thickness = thickness + + if imgui.button("randomize"): + line.data[:, 1] = np.random.rand(100) + + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_floating.py b/examples/guis/imgui_floating.py new file mode 100644 index 000000000..51beb012d --- /dev/null +++ b/examples/guis/imgui_floating.py @@ -0,0 +1,39 @@ +""" +ImGUI floating windows +====================== + +You can add floating and fixed-extent imgui windows that are overlaid on the Figure. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_image(np.random.rand(128, 128), name="image") + + +# a floating window is auto-sized by imgui and can be dragged by the user +@figure.add_imgui_window(location="floating", title="floating", window_flags=imgui.WindowFlags_.none) +def floating_gui(fig): + if imgui.button("randomize"): + fig[0, 0]["image"].data = np.random.rand(128, 128) + + +# a window fixed to a fractional extent (xmin, xmax, ymin, ymax) of the canvas +@figure.add_imgui_window(extent=(0.6, 0.98, 0.05, 0.25), title="fixed") +def fixed_gui(): + imgui.text("fixed to a\nfractional extent") + + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_menu_bar.py b/examples/guis/imgui_menu_bar.py new file mode 100644 index 000000000..e792f0206 --- /dev/null +++ b/examples/guis/imgui_menu_bar.py @@ -0,0 +1,138 @@ +""" +ImGUI menu bar +============== + +You can override ``ImguiWindow.draw()`` to create a window with a menu bar. You can override the `draw()` call when +you need full control of the imgui window. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import fastplotlib as fpl +from fastplotlib.ui import ImguiWindow +from imgui_bundle import imgui + +# the imageio standard images +IMAGES = [ + "camera.png", + "astronaut.png", + "checkerboard.png", + "chelsea.png", + "clock.png", + "coffee.png", + "coins.png", + "horse.png", + "hubble_deep_field.png", + "immunohistochemistry.png", + "moon.png", + "page.png", + "text.png", + "wikkie.png", + "bricks.jpg", + "wood.jpg", +] + +figure = fpl.Figure(size=(700, 560)) +image = figure[0, 0].add_image(iio.imread(f"imageio:{IMAGES[0]}"), name="image") + + +class ImagePicker(ImguiWindow): + """floating window that replaces the image in the subplot with the one that is picked""" + + def __init__(self): + super().__init__() + + self.visible = False + self.picked = IMAGES[0] + + def draw(self): + if not self.visible: + return + + # a height of zero makes imgui auto-size the window to fit the list + imgui.set_next_window_size((220, 0), imgui.Cond_.appearing) + expanded, self.visible = imgui.begin("Open image", True) + + if expanded: + for name in IMAGES: + if imgui.selectable(name, name == self.picked)[0]: + self.picked = name + image.data = iio.imread(f"imageio:{name}") + figure[0, 0].auto_scale() + self.visible = False + + imgui.end() + + +class MenuBar(ImguiWindow): + """menu bar at the top of the Figure, ``update()`` is unused since ``draw()`` is fully overridden""" + + def __init__(self, picker: ImagePicker): + super().__init__() + + self._picker = picker + self._show_version = False + + def draw(self): + imgui.set_next_window_size((self.width, self.height)) + imgui.set_next_window_pos((self.x, self.y)) + + imgui.begin( + f"menu-bar##{self._id_counter}", + p_open=None, + flags=imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_scrollbar + | imgui.WindowFlags_.no_bring_to_front_on_focus + | imgui.WindowFlags_.menu_bar, + ) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("File"): + if imgui.menu_item("Open", "", False)[0]: + self._picker.visible = True + + imgui.end_menu() + + if imgui.begin_menu("Help"): + if imgui.menu_item("Version", "", False)[0]: + self._show_version = True + + imgui.end_menu() + + imgui.end_menu_bar() + + # the popup is opened here and not within the menu, imgui requires that open_popup() and + # begin_popup_modal() are called for the same window + if self._show_version: + self._show_version = False + imgui.open_popup("Version") + + # center the modal on the canvas + imgui.set_next_window_pos( + imgui.get_main_viewport().get_center(), imgui.Cond_.appearing, (0.5, 0.5) + ) + + # p_open draws a close button in the title bar, imgui closes the modal when it is clicked + if imgui.begin_popup_modal("Version", True, imgui.WindowFlags_.always_auto_resize)[0]: + imgui.text(f"fastplotlib version: {fpl.__version__}") + imgui.end_popup() + + imgui.end() + + +picker = ImagePicker() +figure.add_imgui_window(picker, location="floating") +figure.add_imgui_window(MenuBar(picker), location="top", size=30) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_right_click.py b/examples/guis/imgui_right_click.py new file mode 100644 index 000000000..c83ea3759 --- /dev/null +++ b/examples/guis/imgui_right_click.py @@ -0,0 +1,91 @@ +""" +ImGUI right-click popups +======================== + +You can set an imgui popup that is opened by a right-click on a Figure, Subplot or Graphic. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import imageio.v3 as iio +from scipy.ndimage import gaussian_filter +import fastplotlib as fpl +from imgui_bundle import imgui + +data1 = iio.imread("imageio:camera.png").astype(np.float32) +data2 = iio.imread("imageio:moon.png").astype(np.float32) + +figure = fpl.Figure(shape=(1, 2), size=(900, 560), names=["images", "line"]) + +# the popup keeps its state in the graphic's metadata, so one function can be used for both images +state = {"noise": 0.0, "sigma": 1.0, "filter": False} + +img1 = figure[0, 0].add_image(data1, name="img1", metadata=state.copy()) +img2 = figure[0, 0].add_image(data2, name="img2", offset=(550, 0, 0), metadata=state.copy()) + +line = figure[0, 1].add_line(np.sin(np.linspace(0, 4 * np.pi, 100)), name="line") + +raw = {img1: data1, img2: data2} + + +# append elements to the standard right-click menu +@figure.append_imgui_right_click() +def more_items(fig): + imgui.separator() + if imgui.menu_item("Autoscale all subplots", "", False)[0]: + for subplot in fig: + subplot.auto_scale() + + +# a popup set on a subplot replaces the standard menu within that subplot +@figure[0, 1].set_imgui_right_click() +def line_popup(subplot): + imgui.text(f"subplot: {subplot.name}") + imgui.separator() + _, line.thickness = imgui.slider_float("thickness", line.thickness, 1.0, 20.0) + changed, color = imgui.color_edit3("color", tuple(float(c) for c in line.colors)[:3]) + if changed: + line.colors = (*color, 1.0) + + +# a popup can contain any imgui elements, it is not restricted to menu items +def image_processing(image): + ui = image.metadata + + imgui.text(image.name) + imgui.separator() + + changed_noise, ui["noise"] = imgui.slider_float("noise sigma", ui["noise"], 0.0, 100.0) + changed_filter, ui["filter"] = imgui.checkbox("gaussian filter", ui["filter"]) + + imgui.begin_disabled(not ui["filter"]) + changed_sigma, ui["sigma"] = imgui.slider_float("filter sigma", ui["sigma"], 0.1, 10.0) + imgui.end_disabled() + + if imgui.button("reset"): + ui.update(noise=0.0, sigma=1.0, filter=False) + changed_noise = True + + if changed_noise or changed_filter or changed_sigma: + data = raw[image] + np.random.normal(scale=ui["noise"], size=raw[image].shape) + + if ui["filter"]: + data = gaussian_filter(data, sigma=ui["sigma"]) + + image.data = data + + +# the same function on both images, each graphic gets its own popup and is passed to the function +img1.set_imgui_right_click(image_processing) +img2.set_imgui_right_click(image_processing) + +figure.show() +figure[0, 1].camera.maintain_aspect = False + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_top.py b/examples/guis/imgui_top.py index e1f865fe0..5a29534c8 100644 --- a/examples/guis/imgui_top.py +++ b/examples/guis/imgui_top.py @@ -11,8 +11,8 @@ import numpy as np import fastplotlib as fpl -# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +# subclass from ImguiWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui # make some initial data @@ -27,31 +27,29 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", color_mode="uniform") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", color_mode="uniform") -class ImguiExample(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title, window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize) - +class ImguiExample(ImguiWindow): def update(self): imgui.text("This is a top window") # make GUI instance -gui = ImguiExample( - figure, # the figure this GUI instance should live inside - size=30, # width or height of the GUI window within the figure - location="top", # the edge to place this window at - title=" ", # window title +gui = ImguiExample() + +# add it to the top edge of the figure +figure.add_imgui_window( + gui, + location="top", + size=60, + title="top window", + window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize, ) -# add it to the figure -figure.add_gui(gui) - figure.show() # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/guis/sine_cosine_funcs.py b/examples/guis/sine_cosine_funcs.py index 935f9a5a1..be260d782 100644 --- a/examples/guis/sine_cosine_funcs.py +++ b/examples/guis/sine_cosine_funcs.py @@ -11,7 +11,7 @@ import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui @@ -129,9 +129,9 @@ def set_x_val(ev): sine_selector.selection = 50 -class GUIWindow(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title) +class GUIWindow(ImguiWindow): + def __init__(self): + super().__init__() self._p = 1 self._q = 1 @@ -166,14 +166,9 @@ def update(self): self._set_data() -gui = GUIWindow( - figure=figure, - size=100, - location="right", - title="Freq. coeffs" -) +gui = GUIWindow() -figure.add_gui(gui) +figure.add_imgui_window(gui, location="right", size=150, title="Freq. coeffs") figure.show() diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py index 34bf9b903..9782fabdc 100644 --- a/examples/image_volume/image_volume_4d.py +++ b/examples/image_volume/image_volume_4d.py @@ -11,6 +11,7 @@ import numpy as np from scipy.ndimage import gaussian_filter import fastplotlib as fpl +from fastplotlib.ui import ImguiColorbar def generate_data( @@ -67,12 +68,9 @@ def generate_data( alpha_mode="add", ) -hlut = fpl.HistogramLUTTool(voldata, volume) - -figure[0, 0].docks["right"].size = 100 -figure[0, 0].docks["right"].controller.enabled = False -figure[0, 0].docks["right"].add_graphic(hlut) -figure[0, 0].docks["right"].auto_scale(maintain_aspect=False) +# a colorbar with a histogram of the entire 4D dataset +colorbar = ImguiColorbar(images=volume, histogram=np.histogram(voldata, bins=100)) +figure[0, 0].add_imgui_window(colorbar, location="right", size=100) figure.show() diff --git a/examples/image_volume/image_volume_render_modes.py b/examples/image_volume/image_volume_render_modes.py index 36705d17d..943612887 100644 --- a/examples/image_volume/image_volume_render_modes.py +++ b/examples/image_volume/image_volume_render_modes.py @@ -10,7 +10,7 @@ import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiColorbar, ImguiWindow from fastplotlib.graphics.features import VOLUME_RENDER_MODES import imageio.v3 as iio from imgui_bundle import imgui @@ -25,58 +25,52 @@ figure[0, 0].add_image_volume(voldata, name="vol-img") -# add an hlut tool -hlut = fpl.HistogramLUTTool(voldata, figure[0, 0]["vol-img"]) - -figure[0, 0].docks["right"].size = 80 -figure[0, 0].docks["right"].controller.enabled = False -figure[0, 0].docks["right"].add_graphic(hlut) -figure[0, 0].docks["right"].auto_scale(maintain_aspect=False) - - -class GUI(EdgeWindow): - def __init__(self, figure, title="Render options", location="right", size=300): - super().__init__(figure, title=title, location=location, size=size) +# add a colorbar with a histogram of the volume data +colorbar = ImguiColorbar( + images=figure[0, 0]["vol-img"], histogram=np.histogram(voldata, bins=100) +) +figure[0, 0].add_imgui_window(colorbar, location="right", size=100) - # reference to the graphic for convenience - self.graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] +class GUI(ImguiWindow): def update(self): + graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] + imgui.text("Switch render mode:") # add buttons to switch between modes for mode in VOLUME_RENDER_MODES.keys(): if imgui.button(mode): - self.graphic.mode = mode + graphic.mode = mode # add sliders to change iso rendering properties - if self.graphic.mode == "iso": - _, self.graphic.threshold = imgui.slider_float( - "threshold", v=self.graphic.threshold, v_max=255, v_min=1, + if graphic.mode == "iso": + _, graphic.threshold = imgui.slider_float( + "threshold", v=graphic.threshold, v_max=255, v_min=1, ) - _, self.graphic.step_size = imgui.slider_float( - "step_size", v=self.graphic.step_size, v_max=10.0, v_min=0.1, + _, graphic.step_size = imgui.slider_float( + "step_size", v=graphic.step_size, v_max=10.0, v_min=0.1, ) - _, self.graphic.substep_size = imgui.slider_float( - "substep_size", v=self.graphic.substep_size, v_max=10.0, v_min=0.1, + _, graphic.substep_size = imgui.slider_float( + "substep_size", v=graphic.substep_size, v_max=10.0, v_min=0.1, ) - col = imgui.ImVec4((*self.graphic.emissive.rgb, 1)) - _, self.graphic.emissive = imgui.color_picker3("emissive color", col=col) + col = imgui.ImVec4((*graphic.emissive.rgb, 1)) + _, graphic.emissive = imgui.color_picker3("emissive color", col=col) - if self.graphic.mode == "slice": + if graphic.mode == "slice": imgui.text("Select plane defined by:\nax + by + cz + d = 0") - _, a = imgui.slider_float("a", v=self.graphic.plane[0], v_min=-1, v_max=1.0) - _, b = imgui.slider_float("b", v=self.graphic.plane[1], v_min=-1, v_max=1.0) - _, c = imgui.slider_float("c", v=self.graphic.plane[2], v_min=-1, v_max=1.0) + _, a = imgui.slider_float("a", v=graphic.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=graphic.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=graphic.plane[2], v_min=-1, v_max=1.0) - largest_dim = max(self.graphic.data.value.shape) - _, d = imgui.slider_float("d", v=self.graphic.plane[3], v_min=0, v_max=largest_dim * 2) + largest_dim = max(graphic.data.value.shape) + _, d = imgui.slider_float("d", v=graphic.plane[3], v_min=0, v_max=largest_dim * 2) - self.graphic.plane = (a, b, c, d) + graphic.plane = (a, b, c, d) -gui = GUI(figure=figure) -figure.add_gui(gui) +gui = GUI() +figure.add_imgui_window(gui, location="right", size=300, title="Render options") figure.show() diff --git a/examples/image_volume/image_volume_share_buffer.py b/examples/image_volume/image_volume_share_buffer.py index cc9f07915..86bb372e8 100644 --- a/examples/image_volume/image_volume_share_buffer.py +++ b/examples/image_volume/image_volume_share_buffer.py @@ -11,7 +11,7 @@ from imgui_bundle import imgui import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow import imageio.v3 as iio from skimage.filters import gaussian @@ -37,9 +37,9 @@ ) -class GUI(EdgeWindow): - def __init__(self, figure, title="change data buffer", location="right", size=200): - super().__init__(figure, title=title, location=location, size=size) +class GUI(ImguiWindow): + def __init__(self): + super().__init__() self._sigma = 2 def update(self): @@ -62,8 +62,8 @@ def update(self): vol_slice.plane = (a, b, c, d) -gui = GUI(figure) -figure.add_gui(gui) +gui = GUI() +figure.add_imgui_window(gui, location="right", size=200, title="change data buffer") figure.show() diff --git a/examples/image_widget/README.rst b/examples/image_widget/README.rst deleted file mode 100644 index f445f7390..000000000 --- a/examples/image_widget/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -ImageWidget Examples -==================== diff --git a/examples/image_widget/image_widget.py b/examples/image_widget/image_widget.py deleted file mode 100644 index a3c332182..000000000 --- a/examples/image_widget/image_widget.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Image widget -============ - -Example showing the image widget in action. - -Every image in an `ImageWidget` is associated with an interactive Histogram LUT tool and colorbar. Right-click the -colorbar to pick colormaps. -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -import fastplotlib as fpl -import imageio.v3 as iio # not a fastplotlib dependency, only used for examples - -a = iio.imread("imageio:camera.png") -iw = fpl.ImageWidget(data=a, cmap="viridis", figure_kwargs={"size": (700, 560)}) -iw.show() - -# Access ImageGraphics managed by the image widget -iw.figure[0, 0]["image_widget_managed"].data[:50, :50] = 0 -iw.figure[0, 0]["image_widget_managed"].cmap = "gnuplot2" - -# another way to access the image widget managed ImageGraphics -iw.managed_graphics[0].data[450:, 450:] = 255 - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_grid.py b/examples/image_widget/image_widget_grid.py deleted file mode 100644 index 41e964e95..000000000 --- a/examples/image_widget/image_widget_grid.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Image widget grid -================= - -Example showing how to view multiple images in an ImageWidget -""" - -import fastplotlib as fpl -import imageio.v3 as iio - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -img1 = iio.imread("imageio:camera.png") -img2 = iio.imread("imageio:astronaut.png") -img3 = iio.imread("imageio:chelsea.png") -img4 = iio.imread("imageio:wikkie.png") - -iw = fpl.ImageWidget( - data=[img1, img2, img3, img4], - rgb=[False, True, True, True], # mix of grayscale and RGB images - names=["cameraman", "astronaut", "chelsea", "Almar's cat"], - # ImageWidget will sync controllers by default - # by setting `controller_ids=None` we can have independent controllers for each subplot - # this is useful when the images have different dimensions - figure_kwargs={"size": (700, 560), "controller_ids": None}, -) -iw.show() - -figure = iw.figure - -for subplot in figure: - # sometimes the toolbar adds clutter - subplot.toolbar = False - - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_single_video.py b/examples/image_widget/image_widget_single_video.py deleted file mode 100644 index 86ca642fa..000000000 --- a/examples/image_widget/image_widget_single_video.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Image widget Video -================== - -Example showing how to scroll through one or more videos using the ImageWidget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' - -import fastplotlib as fpl -import imageio.v3 as iio -import numpy as np - - -movie = iio.imread("imageio:cockatoo.mp4") - -# Ignore and do not use the next 2 lines -# for the purposes of docs gallery generation we subsample and only use 15 frames -movie_sub = movie[:15, ::12, ::12].copy() -del movie - -iw = fpl.ImageWidget(movie_sub, rgb=True, figure_kwargs={"size": (700, 560)}) - -# ImageWidget supports setting window functions the `time` "t" or `volume` "z" dimension -# These can also be given as kwargs to `ImageWidget` during instantiation -# to set a window function, give a dict in the form of {dim: (func, window_size)} -iw.window_funcs = {"t": (np.mean, 13)} - -# change the window size -iw.window_funcs["t"].window_size = 33 - -# change the function -iw.window_funcs["t"].func = np.max - -# or reset it -iw.window_funcs = None - -iw.show() - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_videos.py b/examples/image_widget/image_widget_videos.py deleted file mode 100644 index 399abbcff..000000000 --- a/examples/image_widget/image_widget_videos.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Image widget videos side by side -================================ - -Example showing how to scroll through one or more videos using the ImageWidget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' - -import fastplotlib as fpl -import imageio.v3 as iio -import numpy as np - - -# load the standard cockatoo video -cockatoo = iio.imread("imageio:cockatoo.mp4") - -# Ignore and do not use the next 2 lines -# for the purposes of docs gallery generation we subsample and only use 15 frames -cockatoo_sub = cockatoo[:15, ::12, ::12].copy() -del cockatoo - -# make a random grayscale video, shape is [t, rows, cols] -np.random.seed(0) -random_data = np.random.rand(*cockatoo_sub.shape[:-1]) - -iw = fpl.ImageWidget( - [random_data, cockatoo_sub], - rgb=[False, True], - figure_shape=(2, 1), # 2 rows, 1 column - figure_kwargs={"size": (700, 940)} -) - -iw.show() - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_viewports_check.py b/examples/image_widget/image_widget_viewports_check.py deleted file mode 100644 index a4c0aea03..000000000 --- a/examples/image_widget/image_widget_viewports_check.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -ImageWidget test viewport rects -=============================== - -Test Figure to test that viewport rects are positioned correctly in an image widget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'hidden' - -import fastplotlib as fpl -import numpy as np - -np.random.seed(0) -a = np.random.rand(6, 15, 10, 10) - -iw = fpl.ImageWidget( - data=[img for img in a], - names=list(map(str, range(6))), - figure_kwargs={"size": (700, 560)}, -) - -for subplot in iw.figure: - subplot.docks["left"].size = 10 - subplot.docks["bottom"].size = 40 - -iw.show() - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py index e3b0ac104..2f6ec992b 100644 --- a/examples/misc/buffer_replace_gc.py +++ b/examples/misc/buffer_replace_gc.py @@ -14,7 +14,7 @@ from typing import Literal import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui @@ -37,14 +37,14 @@ def generate_dataset(size: int) -> dict[str, np.ndarray]: } -class UI(EdgeWindow): +class UI(ImguiWindow): def __init__(self, figure): - super().__init__(figure=figure, size=200, location="right", title="UI") + super().__init__() init_data = datasets["init"] - self._figure["line"].add_line( + figure["line"].add_line( data=init_data["data"], colors=init_data["colors"], name="line" ) - self._figure["scatter"].add_scatter( + figure["scatter"].add_scatter( **init_data, uniform_size=False, uniform_marker=False, @@ -79,7 +79,7 @@ def _replace( figure = fpl.Figure(shape=(3, 1), size=(700, 1600), names=["line", "scatter", "image"]) ui = UI(figure) -figure.add_gui(ui) +figure.add_imgui_window(ui, location="right", size=200, title="UI") figure.show() diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 00e31c977..1e7b30854 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -25,7 +25,7 @@ else: from .layouts import Figure -from .widgets import NDWidget, ImageWidget +from .widgets import NDWidget if len(enumerate_adapters()) < 1: diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 95a941f8b..24a59a7e4 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -180,7 +180,7 @@ def __init__( self._axes: Axes = None - self._right_click_menu = None + self._imgui_right_click = None # store ids of all the WorldObjects that this Graphic manages/uses self._world_object_ids = list() @@ -692,21 +692,111 @@ def add_axes(self): self._axes.update_using_bbox(self.world_object.get_world_bounding_box()) @property - def right_click_menu(self): - return self._right_click_menu + def imgui_right_click(self): + """ + The imgui popup that is opened by a right-click on this graphic. + + Returns + ------- + ImguiPopup | None + + """ + return self._imgui_right_click + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup that is opened by a right-click on this graphic, replaces the popup of the subplot or + Figure for this graphic. Can also be used as a decorator, see the + ``ImguiFigure.set_imgui_right_click`` examples. + + Parameters + ---------- + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup - @right_click_menu.setter - def right_click_menu(self, menu): + """ if not IMGUI: raise ImportError( - "imgui is required to set right-click menus:\npip install imgui_bundle" + "imgui is required to set right-click popups:\npip install imgui_bundle" + ) + + from ..layouts._subplot import Subplot + from ..ui._base import ImguiPopup, _wrap_update_call + + if not isinstance(self._plot_area, Subplot): + raise TypeError( + "graphic must be added to a subplot before setting an imgui right-click popup on it" + ) + + figure = self._plot_area.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError( + "imgui right-click popups can only be set on a graphic in an ImguiFigure" + ) + + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the right-click popup of this graphic. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + from ..ui._base import _wrap_update_call + + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this graphic to append to, set one using " + "`graphic.set_imgui_right_click()`" ) - self._right_click_menu = menu - menu.owner = self + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) - def _fpl_request_right_click_menu(self): - pass + def remove_imgui_right_click(self): + """ + Remove and return the right-click popup of this graphic + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later + + """ + popup = self._imgui_right_click + self._imgui_right_click = None - def _fpl_close_right_click_menu(self): - pass + return popup diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index a04b1c991..1d2359f96 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -30,6 +30,7 @@ TextureYUV, TupleYUV, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -98,6 +99,7 @@ "TextureYUV", "TupleYUV", "ImageCmap", + "ImageGamma", "ImageVmin", "ImageVmax", "ImageInterpolation", diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 12df0b6b7..1d9092de5 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -479,6 +479,34 @@ def set_value(self, graphic, value: float): self._call_event_handlers(event) +class ImageGamma(GraphicFeature): + """gamma correction applied to the image""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new gamma value", + }, + ] + + def __init__(self, value: float, property_name: str = "gamma"): + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.gamma = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + class ImageCmap(GraphicFeature): """colormap for texture""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 2452733d3..908f92347 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -19,6 +19,7 @@ TextureYUV, TupleYUV, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -125,6 +126,15 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) + @property + def gamma(self) -> float: + """gamma correction applied to the image""" + return self._gamma.value + + @gamma.setter + def gamma(self, value: float): + self._gamma.set_value(self, value) + @property def interpolation(self) -> str: """Data interpolation method""" @@ -372,6 +382,7 @@ class ImageGraphic(ImageBase): _features = { "data": TextureArray, "cmap": ImageCmap, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -384,6 +395,7 @@ def __init__( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "nearest", cmap_interpolation: str = "linear", colorspace: ColorspacesRGB = "srgb", @@ -409,6 +421,9 @@ def __init__( colormap to use to display the data. For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" @@ -418,35 +433,39 @@ def __init__( colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" colorspace in which to interpret the provided data. - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. cpu_buffer: bool, default True If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported - * selector tools will not be able to return the data under the selection + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -482,6 +501,7 @@ def __init__( # other graphic features self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) @@ -508,6 +528,7 @@ def __init__( interpolation=self._interpolation.value, pick_write=True, ) + self._material.gamma = gamma # create the _ImageTile world objects, add to group for tile in self._create_tiles(): @@ -641,6 +662,7 @@ def reset_vmin_vmax(self): class ImageYUVGraphic(ImageBase): _features = { "data": TextureYUV, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -651,6 +673,7 @@ def __init__( data: TupleYUV | TextureYUV, vmin: float = 0, vmax: float = 255, + gamma: float = 1.0, interpolation: str = "nearest", colorspace: ColorspacesYUV = "yuv420p", colorrange: ColorRange = "limited", @@ -676,25 +699,28 @@ def __init__( vmax: float, optional, default 255 maximum value for color scaling + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" colorspace: "yuv42p" | "yuv444p" colorspace in which to interpret the provided data. - * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). - The y represents intensity, and is at full resolution. The u and v planes are a - quarter of the size. + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. - * "yuv444p": A lesser common video format. The data is represented as 3 planes - (y, u, and v) similar to yuv420p however the u and v planes are stored - at full resolution. + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. colorrange: Literal["full", "limited"] = "limited", Relevant for yuv colorspaces. Most videos use "limited". * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. - The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits * "full": The luma plane and chroma plane use the full range of the storage format. See the following links from the FFMPEG documentation for more details: @@ -706,11 +732,14 @@ def __init__( on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. @@ -728,12 +757,14 @@ def __init__( self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) self._material = HighlightableImageMaterial( clim=(vmin, vmax), interpolation=self.interpolation, pick_write=True ) + self._material.gamma = gamma wo = pygfx.Image( geometry=pygfx.Geometry(grid=self.data._texture), diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 3d2d064e8..2154acdb8 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -8,6 +8,7 @@ from .features import ( TextureArrayVolume, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -85,6 +86,7 @@ class ImageVolumeGraphic(Graphic): _features = { "data": TextureArrayVolume, "cmap": ImageCmap, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -105,6 +107,7 @@ def __init__( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "linear", cmap_interpolation: str = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), @@ -136,6 +139,9 @@ def __init__( cmap: str, default "plasma" colormap for grayscale volumes + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, default "linear" interpolation method for sampling pixels @@ -202,6 +208,7 @@ def __init__( # other graphic features self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) @@ -234,6 +241,7 @@ def __init__( VolumeMaterialCls = VOLUME_RENDER_MODES[mode] self._material = VolumeMaterialCls(**material_kwargs) + self._material.gamma = gamma self._mode = VolumeRenderMode(mode) @@ -332,6 +340,15 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) + @property + def gamma(self) -> float: + """gamma correction applied to the image""" + return self._gamma.value + + @gamma.setter + def gamma(self, value: float): + self._gamma.set_value(self, value) + @property def interpolation(self) -> str: """Get or set the image data interpolation method""" diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index f166c18ae..edb01f482 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -855,9 +855,6 @@ def export(self, uri: str | Path | bytes, **kwargs): return iio.imwrite(uri, snapshot, **kwargs) - def open_popup(self, *args, **kwargs): - warn("popups only supported by ImguiFigure") - def _fpl_reset_layout(self, *ev): """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event""" self.layout.canvas_resized(self.get_pygfx_render_area()) diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py index 1c308590f..3b3fab12e 100644 --- a/fastplotlib/layouts/_frame.py +++ b/fastplotlib/layouts/_frame.py @@ -115,6 +115,7 @@ def __init__( resizeable, title, docks, + imgui_windows, toolbar_visible, canvas_rect, ): @@ -144,6 +145,9 @@ def __init__( docks: dict[str, PlotArea] subplot dock + imgui_windows: dict[str, ImguiWindow] + imgui windows confined to this subplot, keyed by location + toolbar_visible: bool toolbar visibility @@ -154,6 +158,7 @@ def __init__( self.viewport = viewport self.docks = docks + self._imgui_windows = imgui_windows self._toolbar_visible = toolbar_visible # create rect manager to handle all the backend rect calculations @@ -254,11 +259,35 @@ def rect(self, rect: np.ndarray): self.reset_viewport() def reset_viewport(self): - """reset the viewport rect for the subplot and docks""" + """reset the viewport rect for the subplot, docks, and imgui windows""" # get rect of the render area x, y, w, h = self.get_render_rect() + # imgui edge windows reserve space outboard of the docks + g_left = self._imgui_size("left") + g_top = self._imgui_size("top") + g_right = self._imgui_size("right") + g_bottom = self._imgui_size("bottom") + + # top and bottom imgui windows are inset by the left and right imgui windows + w_g_top_bottom = w - g_left - g_right + x_g_top_bottom = x + g_left + + # set imgui edge window rects + self._set_imgui_rect("left", (x, y, g_left, h)) + self._set_imgui_rect("top", (x_g_top_bottom, y, w_g_top_bottom, g_top)) + self._set_imgui_rect( + "bottom", (x_g_top_bottom, y + h - g_bottom, w_g_top_bottom, g_bottom) + ) + self._set_imgui_rect("right", (x + w - g_right, y, g_right, h)) + + # shrink the render area to fit inside the imgui edge windows + x += g_left + y += g_top + w -= g_left + g_right + h -= g_top + g_bottom + # dock sizes s_left = self.docks["left"].size s_top = self.docks["top"].size @@ -291,6 +320,31 @@ def reset_viewport(self): # set subplot rect self.viewport.rect = x, y, w, h + # toolbar occupies the reserved bottom band of the frame + self._set_toolbar_rect() + + def _imgui_size(self, location: str) -> int: + """thickness in pixels reserved by the imgui edge window at ``location``, 0 if none""" + window = self._imgui_windows.get(location) + return window.size if window is not None else 0 + + def _set_imgui_rect(self, location: str, rect: tuple): + """set the pixel rect of the imgui edge window at ``location``, if present""" + window = self._imgui_windows.get(location) + if window is not None: + window._fpl_set_rect(*(round(v) for v in rect)) + + def _set_toolbar_rect(self): + """set the pixel rect of the subplot toolbar window, if present""" + window = self._imgui_windows.get("toolbar") + if window is None: + return + + x, y, w, h = self.rect + window._fpl_set_rect( + round(x + 1), round(y + h - IMGUI_TOOLBAR_HEIGHT), round(w - 2), IMGUI_TOOLBAR_HEIGHT + ) + def get_render_rect(self) -> tuple[float, float, float, float]: """ Get the actual render area of the subplot, including the docks. diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 9eae4dd12..ac1e81414 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -4,10 +4,13 @@ import numpy +from numpy.typing import NDArray + import pygfx from ..graphics import * from ..graphics._base import Graphic +from ..utils import enums import typing import fastplotlib @@ -33,6 +36,7 @@ def add_image( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "nearest", cmap_interpolation: str = "linear", colorspace: fastplotlib.utils.enums.ColorspacesRGB = "srgb", @@ -59,6 +63,9 @@ def add_image( colormap to use to display the data. For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" @@ -68,34 +75,39 @@ def add_image( colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" colorspace in which to interpret the provided data. - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. cpu_buffer: bool, default True If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -108,6 +120,7 @@ def add_image( vmin, vmax, cmap, + gamma, interpolation, cmap_interpolation, colorspace, @@ -122,6 +135,7 @@ def add_image_volume( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "linear", cmap_interpolation: str = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), @@ -154,6 +168,9 @@ def add_image_volume( cmap: str, default "plasma" colormap for grayscale volumes + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, default "linear" interpolation method for sampling pixels @@ -199,6 +216,7 @@ def add_image_volume( vmin, vmax, cmap, + gamma, interpolation, cmap_interpolation, plane, @@ -213,15 +231,12 @@ def add_image_volume( def add_image_yuv( self, data: ( - tuple[ - numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], - numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], - numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], - ] + tuple[NDArray[numpy.uint8], NDArray[numpy.uint8], NDArray[numpy.uint8]] | fastplotlib.graphics.features._image.TextureYUV ), vmin: float = 0, vmax: float = 255, + gamma: float = 1.0, interpolation: str = "nearest", colorspace: fastplotlib.utils.enums.ColorspacesYUV = "yuv420p", colorrange: fastplotlib.utils.enums.ColorRange = "limited", @@ -248,25 +263,28 @@ def add_image_yuv( vmax: float, optional, default 255 maximum value for color scaling + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" colorspace: "yuv42p" | "yuv444p" colorspace in which to interpret the provided data. - * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). - The y represents intensity, and is at full resolution. The u and v planes are a - quarter of the size. + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. - * "yuv444p": A lesser common video format. The data is represented as 3 planes - (y, u, and v) similar to yuv420p however the u and v planes are stored - at full resolution. + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. colorrange: Literal["full", "limited"] = "limited", Relevant for yuv colorspaces. Most videos use "limited". * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. - The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits * "full": The luma plane and chroma plane use the full range of the storage format. See the following links from the FFMPEG documentation for more details: @@ -278,11 +296,14 @@ def add_image_yuv( on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. @@ -296,6 +317,7 @@ def add_image_yuv( data, vmin, vmax, + gamma, interpolation, colorspace, colorrange, diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 15b3d7c45..ae7102524 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import Literal, Iterable @@ -12,8 +14,10 @@ import pygfx from ._figure import Figure -from ..ui import EdgeWindow, SubplotToolbar, StandardRightClickMenu, Popup, GUI_EDGES -from ..ui import ColormapPicker +from ._rect import RectManager +from ._utils import IMGUI_TOOLBAR_HEIGHT +from ..ui import ImguiWindow, ImguiPopup, SubplotToolbar, StandardRightClickMenu, EDGES +from ..ui._base import _wrap_update_call class ImguiFigure(Figure): @@ -44,9 +48,16 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, - std_right_click_menu: type[Popup] = StandardRightClickMenu, ): - self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} + # edge windows reserve canvas space, keyed by location; floating windows draw over the plots + self._edge_windows: dict[str, ImguiWindow] = {loc: None for loc in EDGES} + self._floating_windows: list[ImguiWindow] = [] + + # figure level right-click popup, and the popup opened by the most recent right-click + self._imgui_right_click: ImguiPopup = None + self._currently_open_imgui_right_click: ImguiPopup = None + + self._right_click_press_pos: imgui.ImVec2 = None super().__init__( shape=shape, @@ -98,35 +109,24 @@ def __init__( self.imgui_renderer.set_gui(self._draw_imgui) - self._subplot_toolbars: np.ndarray[SubplotToolbar] = np.empty( - shape=self._subplots.size, dtype=object - ) - - for i, subplot in enumerate(self._subplots.ravel()): - toolbar = SubplotToolbar(subplot=subplot) - self._subplot_toolbars[i] = toolbar - - self._std_right_click_menu = std_right_click_menu(figure=self) + for subplot in self._subplots.ravel(): + subplot.add_imgui_window( + SubplotToolbar(), location="toolbar", size=IMGUI_TOOLBAR_HEIGHT + ) - self._popups: dict[str, Popup] = {} + self.set_imgui_right_click(StandardRightClickMenu()) self.imgui_show_fps = False self._stats = Stats(self.renderer.device, self.canvas) - self.register_popup(ColormapPicker) - @property def default_imgui_font(self) -> imgui.ImFont: return self._default_imgui_font @property - def std_right_click_menu(self) -> Popup: - return self._std_right_click_menu - - @property - def guis(self) -> dict[str, EdgeWindow]: - """GUI windows added to the Figure""" - return self._guis + def imgui_windows(self) -> dict[str, ImguiWindow]: + """edge imgui windows added to the Figure, keyed by location""" + return self._edge_windows @property def imgui_renderer(self) -> ImguiRenderer: @@ -146,60 +146,236 @@ def _render(self, draw=False): self.canvas.request_draw() def _draw_imgui(self) -> imgui.ImDrawData: - # imgui.new_frame() - - for subplot, toolbar in zip( - self._subplots.ravel(), self._subplot_toolbars.ravel() - ): - if not subplot.toolbar: - # if subplot.toolbar is False + # figure-level windows: edge windows then floating windows + for window in (*self._edge_windows.values(), *self._floating_windows): + if window is None: continue - toolbar.update() + self._layout_imgui_window(window) + window.draw() + + # subplot windows, edge window rects are set by Frame.reset_viewport + for subplot in self._subplots.ravel(): + for location, window in subplot.imgui_windows.items(): + if window is None: + continue + if location == "toolbar" and not subplot.toolbar: + continue + window.draw() + + self._fpl_handle_right_click() + + # the currently open popup is drawn first, opening it closes any other popup that is still open. + # it keeps being drawn after it closes so that it can also draw its own windows + popup = self._currently_open_imgui_right_click + if popup is not None: + popup.draw() + + if self._imgui_right_click is not None and self._imgui_right_click is not popup: + self._imgui_right_click.draw() + + def add_imgui_window( + self, + window: ImguiWindow = None, + *, + location: Literal["left", "right", "top", "bottom", "floating"] = None, + size: int = None, + rect: tuple | np.ndarray = None, + extent: tuple | np.ndarray = None, + title: str = None, + window_flags: imgui.WindowFlags_ = None, + ): + """ + Add an imgui window to the Figure. Can also be used as a decorator, see examples. + + A window can be placed on an edge ("left", "right", "top", "bottom") where it reserves canvas space so it + does not cover the subplots, "floating" for an auto-sized draggable window, or at a fixed fractional or pixel + ``rect`` or ``extent`` of the canvas. An existing window at an edge ``location`` is replaced. - for gui in self.guis.values(): - if gui is not None: - gui.draw_window() + For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide. - for popup in self._popups.values(): - popup.update() + Parameters + ---------- + window: ImguiWindow, optional + an ``ImguiWindow`` instance, omit when decorating - self._std_right_click_menu.update() + location: str, "left" | "right" | "top" | "bottom" | "floating" + edge windows reserve canvas space, "floating" is auto-sized and draggable - # imgui.end_frame() + size: int + edge window thickness in pixels, required for edge windows - # imgui.render() + rect: (x, y, w, h), optional + fractional or pixel rect for a fixed floating window - # return imgui.get_draw_data() + extent: (xmin, xmax, ymin, ymax), optional + fractional or pixel extent for a fixed floating window - def add_gui(self, gui: EdgeWindow): - """ - Add a GUI to the Figure. GUIs can be added to the left or bottom edge. + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. - Parameters - ---------- - gui: EdgeWindow - A GUI EdgeWindow instance + window_flags: ``imgui.WindowFlags_`` + imgui window flags, used when decorating; if not provided, the default depends on placement — edge + windows use ``no_collapse | no_resize | no_title_bar | no_bring_to_front_on_focus`` (custom title bar, + stays behind overlays), floating windows use ``none`` (native title bar, collapsible and movable), + fixed rect/extent windows use ``no_collapse | no_move | no_resize`` (native title bar) + + Examples + -------- + + As a decorator:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure() + figure[0, 0].add_line(np.random.rand(100)) + + @figure.add_imgui_window(location="right", title="controls", size=200) + def gui(fig): # the figure is passed if the function takes an argument + if imgui.button("reset data"): + fig[0, 0].graphics[0].data[:, 1] = np.random.rand(100) + + Instance:: + + figure.add_imgui_window(MyWindow(), location="bottom", size=100) """ - if not isinstance(gui, EdgeWindow): - raise TypeError( - f"GUI must be of type: {EdgeWindow} you have passed a {type(gui)}" + + def decorator(_window): + if isinstance(_window, ImguiWindow): + win = _window + elif callable(_window): + win = ImguiWindow(update_call=_wrap_update_call(_window, self)) + else: + raise TypeError( + "add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` instance" + ) + + win._fpl_add_hook( + figure=self, + subplot=None, + location=location, + size=size, + rect=rect, + extent=extent, + title=title, + window_flags=window_flags, ) + self._register_imgui_window(win) + return _window + + if window is None: + return decorator - location = gui.location + decorator(window) + return window - if location not in GUI_EDGES: + def _register_imgui_window(self, window: ImguiWindow): + """store a figure-level window and reset the layout if it reserves canvas space""" + location = window.location + + if location in EDGES: + if window.size is None: + raise ValueError(f"must provide `size` for an edge window, location: {location}") + self._edge_windows[location] = window + self._fpl_reset_layout() + + elif window._floating or window._rect_manager is not None: + self._floating_windows.append(window) + + else: raise ValueError( - f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" + "imgui window must have a valid `location` (an edge or 'floating'), or a `rect` or `extent`" ) - if self.guis[location] is not None: - raise ValueError(f"GUI already exists in the desired location: {location}") + def append_imgui_window(self, gui: Callable = None, *, location: str = None): + """ + Append imgui elements to an existing edge window. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" + location of the existing window to append to + + """ + if location not in EDGES: + raise ValueError(f"valid locations to append to are: {EDGES}, you have passed: {location}") + + window = self._edge_windows[location] + if window is None: + raise ValueError(f"no imgui window at location to append to: {location}") + + def decorator(_gui): + window._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator - self.guis[location] = gui + return decorator(gui) + def remove_imgui_window(self, location: str) -> ImguiWindow: + """ + Remove and return the edge imgui window at the given location + + Parameters + ---------- + location: str + "left" | "right" | "top" | "bottom" + + Returns + ------- + ImguiWindow + the removed window, it can be added again later + + """ + if location not in EDGES: + raise ValueError(f"valid locations are: {EDGES}, you have passed: {location}") + + window = self._edge_windows[location] + self._edge_windows[location] = None self._fpl_reset_layout() + return window + + def _edge_size(self, edge: str) -> int: + """thickness in pixels reserved by the edge window at ``edge``, 0 if none""" + window = self._edge_windows[edge] + return window.size if window is not None else 0 + + def _layout_imgui_window(self, window: ImguiWindow): + """compute and set the pixel rect of a figure-level imgui window""" + if window._floating: + # imgui auto-sizes a floating window from its content, nothing to compute + return + + width, height = self.canvas.get_logical_size() + + if window._rect_manager is not None: + window._rect_manager.canvas_resized((0, 0, width, height)) + window._fpl_set_rect(*(round(v) for v in window._rect_manager.rect)) + return + + # edge window, spans the full edge minus any perpendicular edge windows + sl, sr = self._edge_size("left"), self._edge_size("right") + st, sb = self._edge_size("top"), self._edge_size("bottom") + mid_y, mid_h = st, height - st - sb + + match window.location: + case "top": + rect = (0, 0, width, st) + case "bottom": + rect = (0, height - sb, width, sb) + case "left": + rect = (0, mid_y, sl, mid_h) + case "right": + rect = (width - sr, mid_y, sr, mid_h) + + window._fpl_set_rect(*(round(v) for v in rect)) def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ @@ -214,53 +390,187 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ width, height = self.canvas.get_logical_size() - x = 0 - y = 0 - - for edge in ["right"]: - if self.guis[edge]: - width -= self._guis[edge].size - for edge in ["bottom"]: - if self.guis[edge]: - height -= self._guis[edge].size + sl, sr = self._edge_size("left"), self._edge_size("right") + st, sb = self._edge_size("top"), self._edge_size("bottom") - for edge in ["top"]: - if self.guis[edge]: - y += self._guis[edge].size - height -= self._guis[edge].size + x = sl + y = st + width = width - sl - sr + height = height - st - sb return x, y, max(1, width), max(1, height) - def register_popup(self, popup: Popup.__class__): + @property + def imgui_right_click(self) -> ImguiPopup | None: + """ + The imgui popup that is opened by a right-click within a subplot, a ``StandardRightClickMenu`` by default. + A popup set on a subplot or graphic replaces it for that subplot or graphic. """ - Register a popup class. Note that this takes the class, not an instance + return self._imgui_right_click + + def set_imgui_right_click( + self, + popup: ImguiPopup | Callable = None, + *, + window_flags: imgui.WindowFlags_ = None, + ): + """ + Set the imgui popup that is opened by a right-click within a subplot, replaces the standard right-click + menu. Can also be used as a decorator, see examples. + + For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide. Parameters ---------- - popup: Popup subclass + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup + + Examples + -------- + + As a decorator:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure() + figure[0, 0].add_line(np.random.rand(100)) + + @figure.set_imgui_right_click() + def popup(fig): # the figure is passed if the function takes an argument + if imgui.menu_item("autoscale", "", False)[0]: + fig.imgui_right_click.subplot.auto_scale() + + Function, the same function can be set on any number of figures, subplots or graphics:: + + def popup(subplot): + imgui.text(f"subplot: {subplot.name}") + + figure[0, 0].set_imgui_right_click(popup) + figure[0, 1].set_imgui_right_click(popup) + + Instance:: + + figure.set_imgui_right_click(MyPopup()) """ - self._popups[popup.name] = popup(self) - def open_popup(self, name: str, pos: tuple[int, int], **kwargs): + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=self, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui: Callable = None): """ - Open a registered popup + Append imgui elements to the Figure's right-click popup, the standard right-click menu by default. Can also + be used as a decorator. Parameters ---------- - name: str - The registered name of the popup + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this figure to append to, set one using " + "`figure.set_imgui_right_click()`" + ) + + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator - pos: int, int - x_pos, y_pos for the popup + return decorator(gui) - kwargs - any additional kwargs to pass to the Popup's open() method + def remove_imgui_right_click(self) -> ImguiPopup: + """ + Remove and return the Figure's right-click popup + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later """ + popup = self._imgui_right_click + self._imgui_right_click = None + + return popup + + def _fpl_handle_right_click(self): + """open the popup of the graphic, subplot or Figure that was right-clicked""" + if imgui.is_mouse_down(1): + if self._right_click_press_pos is None: + self._right_click_press_pos = imgui.get_mouse_pos() + return - if self._popups[name].is_open: + press_pos = self._right_click_press_pos + self._right_click_press_pos = None + + if press_pos is None or not imgui.is_mouse_released(1): + return + + pos = imgui.get_mouse_pos() + + if press_pos != pos: + # right-drag zooms the camera + return + + if imgui.is_window_hovered(imgui.HoveredFlags_.any_window): + # pointer is over an imgui window, not the pygfx render area + return + + for subplot in self._subplots.ravel(): + if subplot.viewport.is_inside(pos.x, pos.y): + break + else: return - self._popups[name].open(pos, **kwargs) + pick_info = subplot.get_pick_info((pos.x, pos.y)) + graphic = pick_info["graphic"] if pick_info is not None else None + + # the most specific popup wins + if graphic is not None and graphic.imgui_right_click is not None: + popup = graphic.imgui_right_click + elif subplot.imgui_right_click is not None: + popup = subplot.imgui_right_click + else: + popup = self._imgui_right_click + + if popup is not None: + self._fpl_open_imgui_right_click(popup, subplot=subplot, graphic=graphic) + + def _fpl_open_imgui_right_click(self, popup: ImguiPopup, subplot, graphic): + """set the popup that is drawn as the open popup, and open it""" + previous = self._currently_open_imgui_right_click + if previous is not None and previous is not popup: + previous._fpl_close() + + self._currently_open_imgui_right_click = popup + popup._fpl_open(subplot=subplot, graphic=graphic) diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index f9534b683..89329a3db 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -62,10 +62,7 @@ def __init__( self._docks = dict() - if "Imgui" in parent.__class__.__name__: - toolbar_visible = True - else: - toolbar_visible = False + toolbar_visible = "Imgui" in parent.__class__.__name__ super().__init__( parent=parent, @@ -83,6 +80,11 @@ def __init__( self.docks[pos] = dv self.children.append(dv) + # imgui windows confined to this subplot, keyed by location + self._imgui_windows = {loc: None for loc in ["left", "right", "top", "bottom", "toolbar"]} + + self._imgui_right_click = None + self._axes = Axes(self) self.scene.add(self.axes.world_object) @@ -93,6 +95,7 @@ def __init__( resizeable=resizeable, title=name, docks=self.docks, + imgui_windows=self._imgui_windows, toolbar_visible=toolbar_visible, canvas_rect=parent.get_pygfx_render_area(), ) @@ -165,6 +168,251 @@ def frame(self) -> Frame: """Frame that the subplot lives in""" return self._frame + @property + def imgui_windows(self) -> dict: + """ + The imgui windows of this subplot, keyed by location. + + The locations are the four edges ["left", "right", "top", "bottom"] and "toolbar" + + Returns + ------- + dict[str, ImguiWindow] + {location: ImguiWindow} + + """ + return self._imgui_windows + + def add_imgui_window( + self, + window=None, + *, + location: str = None, + size: int = None, + title: str = None, + window_flags=None, + ): + """ + Add an imgui window confined to this subplot. Can also be used as a decorator, see the + ``Figure.add_imgui_window`` examples. + + Edge windows ("left", "right", "top", "bottom") reserve space outboard of the subplot dock on that edge. + The "toolbar" location replaces the subplot toolbar. An existing window at a ``location`` is replaced. + + Parameters + ---------- + window: ImguiWindow, optional + an ``ImguiWindow`` instance, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" + edge windows reserve canvas space, "toolbar" replaces the subplot toolbar + + size: int + edge or toolbar thickness in pixels, required for edge windows + + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags, used when decorating, uses the ``ImguiWindow`` default flags if not provided + + """ + figure = self.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError("imgui windows can only be added to a subplot of an ImguiFigure") + + from ..ui._base import ImguiWindow, EDGES, _wrap_update_call + + valid = EDGES + ["toolbar"] + if location not in valid: + raise ValueError( + f"subplot imgui window location must be one of: {valid}, you have passed: {location}" + ) + if location in EDGES and size is None: + raise ValueError(f"must provide `size` for an edge window, location: {location}") + + hook_kwargs = dict(figure=figure, subplot=self, location=location, size=size, title=title) + if window_flags is not None: + hook_kwargs["window_flags"] = window_flags + + def decorator(_window): + if isinstance(_window, ImguiWindow): + win = _window + elif callable(_window): + win = ImguiWindow(update_call=_wrap_update_call(_window, self)) + else: + raise TypeError( + "add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` instance" + ) + + win._fpl_add_hook(**hook_kwargs) + self._imgui_windows[location] = win + + # edge windows reserve space, reset the layout + if location in EDGES: + figure._fpl_reset_layout() + + return _window + + if window is None: + return decorator + + decorator(window) + return window + + def append_imgui_window(self, gui=None, *, location: str = None): + """ + Append imgui elements to an existing window of this subplot. Can also be used as a decorator. Useful for + appending elements to the subplot toolbar with ``location="toolbar"``. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" + location of the existing window to append to + + """ + from ..ui._base import _wrap_update_call + + window = self._imgui_windows.get(location) + if window is None: + raise ValueError(f"no imgui window at location to append to: {location}") + + def decorator(_gui): + window._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_window(self, location: str): + """ + Remove and return the imgui window at the given location + + Parameters + ---------- + location: str + "left" | "right" | "top" | "bottom" | "toolbar" + + Returns + ------- + ImguiWindow + the removed window, it can be added again later + + """ + from ..ui._base import EDGES + + window = self._imgui_windows.get(location) + self._imgui_windows[location] = None + + # edge windows reserve space, reset the layout + if location in EDGES: + self.get_figure()._fpl_reset_layout() + + return window + + @property + def imgui_right_click(self): + """ + The imgui popup that is opened by a right-click within this subplot. + + Returns + ------- + ImguiPopup | None + + """ + return self._imgui_right_click + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup that is opened by a right-click within this subplot, replaces the Figure's popup within + this subplot. Can also be used as a decorator, see the ``ImguiFigure.set_imgui_right_click`` examples. + + Parameters + ---------- + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup + + """ + figure = self.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError( + "imgui right-click popups can only be set on a subplot of an ImguiFigure" + ) + + from ..ui._base import ImguiPopup, _wrap_update_call + + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the right-click popup of this subplot. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + from ..ui._base import _wrap_update_call + + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this subplot to append to, set one using " + "`subplot.set_imgui_right_click()`" + ) + + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_right_click(self): + """ + Remove and return the right-click popup of this subplot + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later + + """ + popup = self._imgui_right_click + self._imgui_right_click = None + + return popup + class Dock(PlotArea): def __init__( diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index 761183f76..9c5492d80 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,9 +1,7 @@ -from ._histogram_lut import HistogramLUTTool from ._textbox import TextBox, Tooltip from ._cursor import Cursor __all__ = [ - "HistogramLUTTool", "TextBox", "Tooltip", "Cursor", diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py deleted file mode 100644 index 8edfb046b..000000000 --- a/fastplotlib/tools/_histogram_lut.py +++ /dev/null @@ -1,431 +0,0 @@ -from math import ceil -from typing import Sequence -import weakref - -import numpy as np - -import pygfx - -from ..utils import subsample_array, RenderQueue -from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic -from ..graphics.utils import pause_events -from ..graphics._base import Graphic -from ..graphics.features import GraphicFeatureEvent -from ..graphics.selectors import LinearRegionSelector - - -def _format_value(value: float): - abs_val = abs(value) - if abs_val < 0.01 or abs_val > 9_999: - return f"{value:.2e}" - else: - return f"{value:.2f}" - - -class HistogramLUTTool(Graphic): - _fpl_support_tooltip = False - - def __init__( - self, - histogram: tuple[np.ndarray, np.ndarray], - images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] | None = None, - **kwargs, - ): - """ - A histogram tool that allows adjusting the vmin, vmax of images. - Also allows changing the cmap LUT for grayscale images and displays a colorbar. - - Parameters - ---------- - histogram: tuple[np.ndarray, np.ndarray] - [frequency, bin_edges], must be 100 bins - - images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] - the images that are managed by the histogram tool - - kwargs: - passed to ``Graphic`` - - """ - - super().__init__(**kwargs) - - if len(histogram) != 2: - raise TypeError - - self._block_reentrance = False - self._images = list() - - self._bin_centers_flanked = np.zeros(120, dtype=np.float64) - self._freq_flanked = np.zeros(120, dtype=np.float32) - - # 100 points for the histogram, 10 points on each side for the flank - line_data = np.column_stack( - [np.zeros(120, dtype=np.float32), np.arange(0, 120)] - ) - - # line that displays the histogram - self._line = LineGraphic( - line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(1, 0, 0) - ) - self._line.world_object.local.scale_x = -1 - - # vmin, vmax selector - self._selector = LinearRegionSelector( - selection=(10, 110), - limits=(0, 119), - size=1.5, - center=0.5, # frequency data are normalized between 0-1 - axis="y", - parent=self._line, - ) - - self._selector.add_event_handler(self._selector_event_handler, "selection") - - self._colorbar = ImageGraphic( - data=np.zeros([120, 2]), interpolation="linear", offset=(1.5, 0, 0) - ) - - # make the colorbar thin - self._colorbar.world_object.local.scale_x = 0.15 - self._colorbar.add_event_handler(self._open_cmap_picker, "click") - - # colorbar ruler - self._ruler = pygfx.Ruler( - end_pos=(0, 119, 0), - alpha_mode="solid", - render_queue=RenderQueue.axes, - tick_side="right", - tick_marker="tick_right", - tick_format=self._ruler_tick_map, - min_tick_distance=10, - ) - self._ruler.local.x = 1.75 - - # TODO: need to auto-scale using the text so it appears nicely, will do later - self._ruler.visible = False - - self._text_vmin = TextGraphic( - text="", - font_size=16, - anchor="top-left", - outline_color="black", - outline_thickness=0.5, - alpha_mode="solid", - ) - # this is to make sure clicking text doesn't conflict with the selector tool - # since the text appears near the selector tool - self._text_vmin.world_object.material.pick_write = False - - self._text_vmax = TextGraphic( - text="", - font_size=16, - anchor="bottom-left", - outline_color="black", - outline_thickness=0.5, - alpha_mode="solid", - ) - self._text_vmax.world_object.material.pick_write = False - - # add all the world objects to a pygfx.Group - wo = pygfx.Group() - wo.add( - self._line.world_object, - self._selector.world_object, - self._colorbar.world_object, - self._ruler, - self._text_vmin.world_object, - self._text_vmax.world_object, - ) - self._set_world_object(wo) - - # for convenience, a list that stores all the graphics managed by the histogram LUT tool - self._children = [ - self._line, - self._selector, - self._colorbar, - self._text_vmin, - self._text_vmax, - ] - - # set histogram - self.histogram = histogram - - # set the images - self.images = images - - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - for child in self._children: - # need all of them to call the add_plot_area_hook so that events are connected correctly - # example, the linear region selector needs all the canvas events to be connected - child._fpl_add_plot_area_hook(plot_area) - - if hasattr(self._plot_area, "size"): - # if it's in a dock area - self._plot_area.size = 80 - - # disable the controller in this plot area - self._plot_area.controller.enabled = False - self._plot_area.auto_scale(maintain_aspect=False) - - # tick text for colorbar ruler doesn't show without this call - self._ruler.update(plot_area.camera, plot_area.canvas.get_logical_size()) - - def _ruler_tick_map(self, bin_index, *args): - return f"{self._bin_centers_flanked[int(bin_index)]:.2f}" - - @property - def histogram(self) -> tuple[np.ndarray, np.ndarray]: - """histogram [frequency, bin_centers]. Frequency is flanked by 10 zeros on both sides""" - return self._freq_flanked, self._bin_centers_flanked - - @histogram.setter - def histogram( - self, histogram: tuple[np.ndarray, np.ndarray], limits: tuple[int, int] = None - ): - """set histogram with pre-compuated [frequency, edges], must have exactly 100 bins""" - - freq, edges = histogram - - if freq.max() > 0: - # if the histogram is made from an empty array, then the max freq will be 0 - # we don't want to divide by 0 because then we just get nans - freq = freq / freq.max() - - bin_centers = 0.5 * (edges[1:] + edges[:-1]) - - step = bin_centers[1] - bin_centers[0] - - under_flank = np.linspace(bin_centers[0] - step * 10, bin_centers[0] - step, 10) - over_flank = np.linspace( - bin_centers[-1] + step, bin_centers[-1] + step * 10, 10 - ) - self._bin_centers_flanked[:] = np.concatenate( - [under_flank, bin_centers, over_flank] - ) - - self._freq_flanked[10:110] = freq - - self._line.data[:, 0] = self._freq_flanked - self._colorbar.data = np.column_stack( - [self._bin_centers_flanked, self._bin_centers_flanked] - ) - - # self.vmin, self.vmax = bin_centers[0], bin_centers[-1] - - if hasattr(self, "plot_area"): - self._ruler.update( - self._plot_area.camera, self._plot_area.canvas.get_logical_size() - ) - - @property - def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: - """get or set the managed images""" - return tuple(self._images) - - @images.setter - def images(self, new_images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] | None): - self._disconnect_images() - self._images.clear() - - if new_images is None: - return - - if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): - new_images = [new_images] - - if not all( - [ - isinstance(image, (ImageGraphic, ImageVolumeGraphic)) - for image in new_images - ] - ): - raise TypeError - - for image in new_images: - if image.cmap is not None: - self._colorbar.visible = True - break - else: - self._colorbar.visible = False - - self._images = list(new_images) - - # reset vmin, vmax using first image - self.vmin = self._images[0].vmin - self.vmax = self._images[0].vmax - - if self._images[0].cmap is not None: - self._colorbar.cmap = self._images[0].cmap - - # connect event handlers - for image in self._images: - image.add_event_handler(self._image_event_handler, "vmin", "vmax") - image.add_event_handler(self._disconnect_images, "deleted") - if image.cmap is not None: - image.add_event_handler( - self._image_event_handler, "vmin", "vmax", "cmap" - ) - - def _disconnect_images(self, *args): - """disconnect event handlers of the managed images""" - for image in self._images: - for ev, handlers in image.event_handlers: - if self._image_event_handler in handlers: - image.remove_event_handler(self._image_event_handler, ev) - - def _image_event_handler(self, ev): - """when the image vmin, vmax, or cmap changes it will update the HistogramLUTTool""" - new_value = ev.info["value"] - setattr(self, ev.type, new_value) - - @property - def cmap(self) -> str: - """get or set the colormap, only for grayscale images""" - return self._colorbar.cmap - - @cmap.setter - def cmap(self, name: str): - if self._block_reentrance: - return - - if name is None: - return - - self._block_reentrance = True - try: - self._colorbar.cmap = name - - with pause_events( - *self._images, event_handlers=[self._image_event_handler] - ): - for image in self._images: - if image.cmap is None: - # rgb(a) images have no cmap - continue - - image.cmap = name - except Exception as exc: - # raise original exception - raise exc # vmax setter has raised. The lines above below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._block_reentrance = False - - @property - def vmin(self) -> float: - """get or set the vmin, the lower contrast limit""" - # no offset or rotation so we can directly use the world space selection value - index = int(self._selector.selection[0]) - return self._bin_centers_flanked[index] - - @vmin.setter - def vmin(self, value: float): - if self._block_reentrance: - return - self._block_reentrance = True - try: - index_min = np.searchsorted(self._bin_centers_flanked, value) - with pause_events( - self._selector, - *self._images, - event_handlers=[ - self._selector_event_handler, - self._image_event_handler, - ], - ): - self._selector.selection = (index_min, self._selector.selection[1]) - - self._colorbar.vmin = value - - self._text_vmin.text = _format_value(value) - self._text_vmin.offset = (-0.45, self._selector.selection[0], 0) - - for image in self._images: - image.vmin = value - - except Exception as exc: - # raise original exception - raise exc # vmax setter has raised. The lines above below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._block_reentrance = False - - @property - def vmax(self) -> float: - """get or set the vmax, the upper contrast limit""" - # no offset or rotation so we can directly use the world space selection value - index = int(self._selector.selection[1]) - return self._bin_centers_flanked[index] - - @vmax.setter - def vmax(self, value: float): - if self._block_reentrance: - return - - self._block_reentrance = True - try: - index_max = np.searchsorted(self._bin_centers_flanked, value) - with pause_events( - self._selector, - *self._images, - event_handlers=[ - self._selector_event_handler, - self._image_event_handler, - ], - ): - self._selector.selection = (self._selector.selection[0], index_max) - - self._colorbar.vmax = value - - self._text_vmax.text = _format_value(value) - self._text_vmax.offset = (-0.45, self._selector.selection[1], 0) - - for image in self._images: - image.vmax = value - - except Exception as exc: - # raise original exception - raise exc # vmax setter has raised. The lines above below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._block_reentrance = False - - def _selector_event_handler(self, ev: GraphicFeatureEvent): - """when the selector's selctor has changed, it will update the vmin, vmax, or both""" - selection = ev.info["value"] - index_min = int(selection[0]) - vmin = self._bin_centers_flanked[index_min] - - index_max = int(selection[1]) - vmax = self._bin_centers_flanked[index_max] - - match ev.info["change"]: - case "min": - self.vmin = vmin - case "max": - self.vmax = vmax - case _: - self.vmin, self.vmax = vmin, vmax - - def _open_cmap_picker(self, ev): - """open imgui cmap picker""" - # check if right click - if ev.button != 2: - return - - pos = ev.x, ev.y - - self._plot_area.get_figure().open_popup("colormap-picker", pos, lut_tool=self) - - def _fpl_prepare_del(self): - """cleanup, need to disconnect events and remove image references for proper garbage collection""" - self._disconnect_images() - self._images.clear() - - for i in range(len(self._children)): - g = self._children.pop(0) - g._fpl_prepare_del() - del g diff --git a/fastplotlib/ui/__init__.py b/fastplotlib/ui/__init__.py index a1e57a9c5..7f6a6ae3d 100644 --- a/fastplotlib/ui/__init__.py +++ b/fastplotlib/ui/__init__.py @@ -1,3 +1,5 @@ -from ._base import BaseGUI, Window, EdgeWindow, Popup, GUI_EDGES +from ._base import ImguiBase, ImguiWindow, ImguiPopup, EDGES, LOCATIONS +from ._utils import ChangeFlag from ._subplot_toolbar import SubplotToolbar -from .right_click_menus import StandardRightClickMenu, ColormapPicker +from ._colorbar import ImguiColorbar +from .right_click_menus import StandardRightClickMenu diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 058ee71f3..47f828c1c 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -1,16 +1,38 @@ -import enum +from __future__ import annotations +import inspect +from collections.abc import Callable +from functools import partial from typing import Literal -import numpy as np from imgui_bundle import imgui -from ..layouts._figure import Figure +from ..layouts._rect import RectManager -GUI_EDGES = ["right", "bottom", "top"] +# edges that reserve space, ordered as they are carved from the render area +EDGES = ["left", "right", "top", "bottom"] +# all valid keyed locations, "toolbar" is subplot only, "floating" uses auto-placement +LOCATIONS = EDGES + ["toolbar", "floating"] -class BaseGUI: + +def _wrap_update_call(func: Callable, parent) -> Callable: + """ + Wrap an imgui draw function for use as a window or popup update call. The parent, a ``Figure``, ``Subplot`` or + ``Graphic``, is passed as the only positional arg if the function accepts one, otherwise the function is called + with no args. + """ + params = inspect.signature(func).parameters.values() + takes_arg = any( + p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL) + for p in params + ) + if takes_arg: + return partial(func, parent) + return func + + +class ImguiBase: """ Base class for all ImGUI based GUIs, windows and popups @@ -22,51 +44,106 @@ class BaseGUI: ID_COUNTER: int = 0 def __init__(self): - BaseGUI.ID_COUNTER += 1 - self._id_counter = BaseGUI.ID_COUNTER + ImguiBase.ID_COUNTER += 1 + self._id_counter = ImguiBase.ID_COUNTER - def update(self): + def draw(self): """must be implemented in subclass""" raise NotImplementedError -class Window(BaseGUI): - """Base class for imgui windows drawn within Figures""" +class ImguiWindow(ImguiBase): + def __init__(self, update_call: Callable = None): + """ + An imgui window drawn within a Figure. Subclass and implement ``update()`` to draw imgui elements, or pass a + callable as ``update_call`` (this is what the ``add_imgui_window()`` decorator does). + + Windows are not added directly, use ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()`` which + provide the host and placement, i.e. location, size, window flags, etc., via ``_fpl_add_hook()``. - pass + Parameters + ---------- + update_call: callable + a callable that draws imgui elements, used instead of ``update()`` when decorating, see ``add_imgui_window`` + """ + super().__init__() + + # imgui element draw calls, run in order within the window on each render + if update_call is None: + self._update_calls = [self.update] + else: + self._update_calls = [update_call] + + # host and placement, set by the host in add_imgui_window() via _fpl_add_hook() + self._figure = None + self._subplot = None + self._location = None + self._size = None + self._rect_manager = None + self._floating = False + self._title = None + self._window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + ) -class EdgeWindow(Window): - def __init__( + # pixel rect, set by the host on each layout pass + self._x, self._y, self._width, self._height = 0, 0, 0, 0 + + # resize and collapse state, only used by figure-level resizeable edge windows + self._resize_cursor_set = False + self._resize_blocked = False + self._right_gui_resizing = False + self._separator_thickness = 14.0 + self._collapsed = False + self._old_size = None + + def _fpl_add_hook( self, - figure: Figure, - size: int, - location: Literal["bottom", "right", "top"], - title: str, - window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar, - *args, - **kwargs, + figure, + subplot=None, + location: Literal["left", "right", "top", "bottom", "toolbar", "floating"] = None, + size: int = None, + rect: tuple = None, + extent: tuple = None, + title: str = None, + window_flags: imgui.WindowFlags_ = None, ): """ - A base class for imgui windows displayed at the bottom or top edge of a Figure + Set the host and placement of this window, called by ``Figure.add_imgui_window()`` or + ``Subplot.add_imgui_window()``. Parameters ---------- - figure: Figure - Figure instance that this window will be placed in + figure: ImguiFigure + the figure this window is drawn in + + subplot: Subplot, optional + the subplot this window is confined to, ``None`` for figure-level windows + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" | "floating" + edge and toolbar windows reserve canvas space, "floating" is auto-sized and draggable size: int - width or height of the window, depending on its location + edge or toolbar thickness in pixels + + rect: (x, y, w, h), optional + fractional or pixel rect for a fixed floating window - location: str, "bottom" | "right" - location of the window + extent: (xmin, xmax, ymin, ymax), optional + fractional or pixel extent for a fixed floating window - title: str - window title + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. - window_flags: enum.IntFlag - Window flag enum, can be compared with ``|`` operator. Valid flags are: + window_flags: ``imgui.WindowFlags_`` + window flag enum, can be combined with the ``|`` operator. If not provided, the default depends on the + placement: edge and toolbar windows use ``no_collapse | no_resize | no_title_bar | + no_bring_to_front_on_focus`` (custom title bar, and they stay behind floating and fixed overlays); + floating windows use ``none`` (native imgui title bar, collapsible and movable); fixed rect/extent + windows use ``no_collapse | no_move | no_resize`` (native imgui title bar). Valid flags are: .. code-block:: py @@ -94,52 +171,71 @@ def __init__( imgui.WindowFlags_.no_decoration imgui.WindowFlags_.no_inputs - *args - additional args for the GUI - - **kwargs - additional kwargs for teh GUI """ - super().__init__() - - if location not in GUI_EDGES: - f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" - self._figure = figure - self._size = size + self._subplot = subplot self._location = location + self._size = int(size) if size is not None else None self._title = title + self._floating = location == "floating" + + if rect is not None: + width, height = figure.canvas.get_logical_size() + self._rect_manager = RectManager(*rect, (0, 0, width, height)) + elif extent is not None: + width, height = figure.canvas.get_logical_size() + self._rect_manager = RectManager.from_extent(extent, (0, 0, width, height)) + + if window_flags is None: + # edge and toolbar windows draw their own title bar; floating and fixed windows use the native + # imgui title bar so they can be collapsed, and floating windows can also be moved + if location in EDGES or location == "toolbar": + # reserved windows never come to front on focus, otherwise clicking one would bury a + # floating or fixed overlay drawn over it and make the overlay inaccessible + window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_bring_to_front_on_focus + ) + elif location == "floating": + window_flags = imgui.WindowFlags_.none + else: + # fixed rect or extent window + window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_move + | imgui.WindowFlags_.no_resize + ) self._window_flags = window_flags - self._resize_cursor_set = False - self._resize_blocked = False - self._right_gui_resizing = False - - self._separator_thickness = 14.0 - - self._collapsed = False - self._old_size = self.size - - self._x, self._y, self._width, self._height = self.get_rect() - - self._figure.canvas.add_event_handler(self._set_rect, "resize") + @property + def location(self) -> str: + """location of the window""" + return self._location @property def size(self) -> int | None: - """width or height of the edge window""" + """edge or toolbar thickness in pixels, ``None`` for floating and fractional windows""" return self._size @size.setter - def size(self, value): + def size(self, value: int): if not isinstance(value, int): raise TypeError(f"{self.__class__.__name__}.size must be an ") self._size = value - self._set_rect() + # reserving windows change the layout when resized + if self._reserves and self._figure is not None: + self._figure._fpl_reset_layout() @property - def location(self) -> str: - """location of the window""" - return self._location + def window_flags(self) -> imgui.WindowFlags_: + """imgui window flags""" + return self._window_flags + + @window_flags.setter + def window_flags(self, flags: imgui.WindowFlags_): + self._window_flags = flags @property def x(self) -> int: @@ -153,7 +249,7 @@ def y(self) -> int: @property def width(self) -> int: - """with the window""" + """width of the window""" return self._width @property @@ -161,47 +257,14 @@ def height(self) -> int: """height of the window""" return self._height - def _set_rect(self, *args): - self._x, self._y, self._width, self._height = self.get_rect() - self._figure._fpl_reset_layout() - - def get_rect(self) -> tuple[int, int, int, int]: - """ - Compute the rect that defines the area this GUI is drawn to - - Returns - ------- - int, int, int, int - x_pos, y_pos, width, height - - """ - - width_canvas, height_canvas = self._figure.canvas.get_logical_size() - - match self._location: - case "bottom": - x_pos = 0 - y_pos = height_canvas - self.size - width, height = (width_canvas, self.size) - - case "right": - x_pos, y_pos = (width_canvas - self.size, 0) - width, height = (self.size, height_canvas) - - if self._figure.guis["bottom"] is not None: - height -= self._figure.guis["bottom"].size - - if self._figure.guis["top"] is not None: - # decrease the height - height -= self._figure.guis["top"].size - # increase the y start - y_pos += self._figure.guis["top"].size - - case "top": - x_pos, y_pos = (0, 0) - width, height = (width_canvas, self.size) + @property + def _reserves(self) -> bool: + """whether this window reserves canvas space, i.e. edge or toolbar windows""" + return self._location in EDGES or self._location == "toolbar" - return x_pos, y_pos, width, height + def _fpl_set_rect(self, x: int, y: int, width: int, height: int): + """set the pixel rect, called by the host on each layout pass""" + self._x, self._y, self._width, self._height = x, y, width, height def _draw_resize_handle(self): if self._location not in ("bottom", "right"): @@ -374,69 +437,201 @@ def _draw_title(self, title: str): imgui.dummy(imgui.ImVec2(win_width, box_size.y)) - def draw_window(self): + def draw(self): """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" # window position & size - x, y, w, h = self.get_rect() - imgui.set_next_window_size((self.width, self.height)) - imgui.set_next_window_pos((self.x, self.y)) - flags = self._window_flags + if self._floating: + # floating windows are auto-sized by imgui, only set the initial position + imgui.set_next_window_pos((self.x, self.y), imgui.Cond_.appearing) + else: + imgui.set_next_window_size((self.width, self.height)) + imgui.set_next_window_pos((self.x, self.y)) + + # append the id to keep the window unique without changing the visible title + expanded = imgui.begin(f"{self._title or ''}##{self._id_counter}", p_open=None, flags=self._window_flags) + + if self._reserves: + # edge and toolbar windows draw a custom title bar and collapse via the resize handle + # resize handle for right and bottom edge windows on the figure + if self._subplot is None and self._location in ("bottom", "right"): + self._draw_resize_handle() + + # push ID to prevent conflict between multiple figs with same UI + imgui.push_id(self._id_counter) + + # collapse the UI if the separator state is collapsed + # otherwise the UI renders partially on the separator for "right" guis and it looks weird + main_height = 1.0 if self._collapsed else 0.0 + imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) + + if self._title is not None: + self._draw_title(self._title) + + imgui.indent(6.0) + # draw imgui elements from the subclass or decorated function(s) + for update_call in self._update_calls: + update_call() + + imgui.end_child() + imgui.pop_id() + + elif expanded: + # floating and fixed windows use the native imgui title bar; only draw when not collapsed + imgui.push_id(self._id_counter) + for update_call in self._update_calls: + update_call() + imgui.pop_id() - # begin window - imgui.begin(self._title, p_open=None, flags=flags) + # end the window + imgui.end() - # resize handle for right and bottom windows - self._draw_resize_handle() + def update(self): + """Implement your GUI here and it will be drawn within the window. See the GUI examples""" + raise NotImplementedError - # push ID to prevent conflict between multiple figs with same UI - imgui.push_id(self._id_counter) - # collapse the UI if the separator state is collapsed - # otherwise the UI renders partially on the separator for "right" guis and it looks weird - main_height = 1.0 if self._collapsed else 0.0 - imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) +class ImguiPopup(ImguiBase): + def __init__(self, update_call: Callable = None): + """ + An imgui popup drawn within a Figure, opened by a right-click. Subclass and implement ``update()`` to draw + imgui elements, or pass a callable as ``update_call``. - self._draw_title(self._title) + Popups are not added directly, use ``ImguiFigure.set_imgui_right_click()``, + ``Subplot.set_imgui_right_click()`` or ``Graphic.set_imgui_right_click()`` which provide the parent and + window flags via ``_fpl_add_hook()``. - imgui.indent(6.0) - # draw stuff from subclass into window - self.update() + Parameters + ---------- + update_call: callable + a callable that draws imgui elements, used instead of ``update()``, see ``set_imgui_right_click`` - imgui.end_child() + """ + super().__init__() - # pop ID - imgui.pop_id() + if update_call is None: + self._update_calls = [self.update] + else: + self._update_calls = [update_call] - # end the window - imgui.end() + # parent, set by the parent in set_imgui_right_click() via _fpl_add_hook() + self._figure = None + self._parent = None + self._window_flags = imgui.WindowFlags_.none - def update(self): - """Implement your GUI here and it will be drawn within the window. See the GUI examples""" - raise NotImplementedError + # popups are identified by a str id, the counter keeps it unique between popups + self._popup_id = f"popup##{self._id_counter}" + # what this popup was opened on, set by the right-click dispatch in Subplot + self._subplot = None + self._graphic = None -class Popup(BaseGUI): - def __init__(self, figure: Figure, *args, **kwargs): + self._open_requested = False + self._pos = None + self._is_open = False + + def _fpl_add_hook( + self, + figure, + parent, + window_flags: imgui.WindowFlags_ = None, + ): """ - Base class for creating ImGUI popups within Figures + Set the parent of this popup, called by ``set_imgui_right_click()``. Parameters ---------- - figure: Figure - Figure instance - *args - any args to pass to subclass constructor + figure: ImguiFigure + the figure this popup is drawn in - **kwargs - any kwargs to pass to subclass constructor - """ + parent: ImguiFigure | Subplot | Graphic + the object this popup is set on - super().__init__() + window_flags: ``imgui.WindowFlags_`` + window flag enum, can be combined with the ``|`` operator, see ``ImguiWindow._fpl_add_hook`` for the + valid flags + """ self._figure = figure + self._parent = parent - self.is_open = False + if window_flags is not None: + self._window_flags = window_flags - def open(self, pos: tuple[int, int], *args, **kwargs): - """implement in subclass""" + @property + def parent(self): + """the object this popup is set on, an ``ImguiFigure``, ``Subplot`` or ``Graphic``""" + return self._parent + + @property + def subplot(self): + """the subplot this popup was opened in""" + return self._subplot + + @property + def graphic(self): + """the graphic this popup was opened on, ``None`` if it was not opened on a graphic""" + return self._graphic + + @property + def is_open(self) -> bool: + """whether the popup is currently open""" + return self._is_open + + @property + def window_flags(self) -> imgui.WindowFlags_: + """imgui window flags""" + return self._window_flags + + @window_flags.setter + def window_flags(self, flags: imgui.WindowFlags_): + self._window_flags = flags + + def open(self, pos: tuple[int, int] = None): + """ + Request that this popup is opened on the next render. + + Parameters + ---------- + pos: (int, int), optional + canvas position of the popup, imgui uses the current mouse position if not provided + + """ + self._pos = pos + self._open_requested = True + + def _fpl_open(self, subplot, graphic): + """set what the popup is opened on and open it, called by the right-click dispatch in ``Subplot``""" + self._subplot = subplot + self._graphic = graphic + self.open() + + def _fpl_close(self): + """called when another popup replaces this one as the open popup""" + self._is_open = False + + def draw(self): + """helps simplify using imgui by managing the popup open state, and pushing/popping the ID""" + if self._open_requested: + self._open_requested = False + if self._pos is not None: + imgui.set_next_window_pos(self._pos) + imgui.open_popup(self._popup_id) + + if imgui.begin_popup(self._popup_id, self._window_flags): + self._is_open = True + + # push ID to prevent conflict between multiple figs with same UI + imgui.push_id(self._id_counter) + + for update_call in self._update_calls: + update_call() + + imgui.pop_id() + imgui.end_popup() + + else: + self._is_open = False + + def update(self): + """Implement your GUI here and it will be drawn within the popup. See the GUI examples""" raise NotImplementedError diff --git a/fastplotlib/ui/_colorbar.py b/fastplotlib/ui/_colorbar.py new file mode 100644 index 000000000..7de048af9 --- /dev/null +++ b/fastplotlib/ui/_colorbar.py @@ -0,0 +1,635 @@ +import numpy as np +import wgpu +from cmap import Colormap +from imgui_bundle import imgui + +from ..graphics import ImageGraphic, ImageVolumeGraphic +from ..utils.functions import COLORMAP_NAMES, quick_min_max +from ._base import ImguiWindow + + +class ImguiColorbar(ImguiWindow): + LUT_HEIGHT = 256 + TEX_WIDTH = 2 + HANDLE_HEIGHT = 8 + HANDLE_OVERHANG = 3 # how far a handle extends past the bar on each side + BAR_BORDER = 1.0 # width of the outline drawn around the bar image + HIST_WIDTH = 50 # width in pixels of the optional histogram drawn left of the bar + HIST_GAP = 4 # gap in pixels between the histogram and the bar + FILL_OVERHANG = 4 # how far the vmin/vmax fill and lines extend past the histogram line-plot + + def __init__( + self, + images: ImageGraphic | ImageVolumeGraphic | list, + histogram: tuple[np.ndarray, np.ndarray] | None = None, + data_range: tuple[float, float] | None = None, + bar_width: int = 16, + region_drag: bool = True, + ): + """ + An imgui colorbar with draggable vmin/vmax handles, an optional histogram, a gamma slider, and a + right-click colormap picker. + + Parameters + ---------- + images: ImageGraphic | ImageVolumeGraphic | list + the image(s) whose vmin, vmax and cmap this colorbar controls + + histogram: tuple[np.ndarray, np.ndarray], optional + a precomputed ``(counts, edges)`` histogram drawn to the left of the bar. It is not recomputed when the + image data changes, set the ``histogram`` property to update it. + + data_range: (min, max), optional + the value range spanned by the bar. Defaults to the histogram edges if a histogram is provided, + otherwise to the data range of the first image. + + bar_width: int + width of the colored bar in pixels + + region_drag: bool + if ``True``, dragging between the handles shifts the vmin/vmax window without changing its width + """ + super().__init__() + + if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): + images = [images] + self._images = list(images) + if len(self._images) == 0: + raise ValueError("must provide at least one image") + + image = self._images[0] + self._vmin = float(image.vmin) + self._vmax = float(image.vmax) + # rgb(a) images have no cmap, display the bar with "gray" so vmin, vmax are still adjustable + self._cmap_name = image.cmap if image.cmap is not None else "gray" + + self._gamma = 1.0 + self._bar_width = int(bar_width) + self._region_drag = bool(region_drag) + + # offset in data units between the grabbed value and the value under the cursor, captured when a drag + # starts so the handle tracks the cursor without jumping + self._grab_offset = 0.0 + + # prevents feedback loops when syncing vmin, vmax, cmap between this colorbar and the images + self._block_reentrance = False + + # GPU resources, created in _fpl_add_hook() once the figure and its device are known + self._device = None + self._bar_texture = None + self._bar_tex_id = None + self._picker_tex_ids = dict() + + # setting the histogram also sets the value axis to the histogram edges + self._histogram = None + self.histogram = histogram + + # data_range defaults to the histogram edges, otherwise the data range of the first image + if data_range is None: + if self._histogram is not None: + counts, edges = self._histogram + data_range = (float(edges[0]), float(edges[-1])) + else: + data_range = quick_min_max(image.data.value) + self._data_min, self._data_max = self._validate_range(data_range) + + def _fpl_add_hook( + self, + figure, + subplot=None, + location: str = None, + size: int = None, + rect: tuple = None, + extent: tuple = None, + title: str = "", + window_flags=None, + ): + super()._fpl_add_hook( + figure, + subplot=subplot, + location=location, + size=size, + rect=rect, + extent=extent, + title=title, + window_flags=window_flags, + ) + + # the colorbar manages its own layout and should never show a scrollbar + self.window_flags = self._window_flags | imgui.WindowFlags_.no_scrollbar + + self._device = figure.renderer.device + + # a preview texture for each non-qualitative colormap, used in the picker + for category, names in COLORMAP_NAMES.items(): + if category == "qualitative": + continue + for name in names: + self._picker_tex_ids[name] = self._make_picker_texture(name) + + self._bar_texture = self._device.create_texture( + size=(self.TEX_WIDTH, self.LUT_HEIGHT, 1), + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=wgpu.TextureFormat.rgba8unorm, + mip_level_count=1, + sample_count=1, + ) + self._bar_tex_id = figure.imgui_renderer.backend.register_texture( + self._bar_texture.create_view() + ) + self._update_bar_texture() + + # sync the colorbar when an image's vmin, vmax, cmap, or gamma is changed elsewhere + for image in self._images: + self._connect_image(image) + + @property + def images(self) -> tuple: + """get or set the images managed by this colorbar""" + return tuple(self._images) + + @images.setter + def images(self, new_images): + self._disconnect_images() + if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): + new_images = [new_images] + self._images = list(new_images) + + # adopt the vmin, vmax, and cmap of the new first image + image = self._images[0] + self._vmin = float(image.vmin) + self._vmax = float(image.vmax) + self._cmap_name = image.cmap if image.cmap is not None else "gray" + self._update_bar_texture() + + for img in self._images: + self._connect_image(img) + + @property + def cmap(self) -> str: + """get or set the colormap""" + return self._cmap_name + + @cmap.setter + def cmap(self, name: str): + if self._block_reentrance or name is None or name == self._cmap_name: + return + self._block_reentrance = True + try: + self._cmap_name = name + self._update_bar_texture() + for image in self._images: + if image.cmap is None: + # rgb(a) images have no cmap + continue + image.cmap = name + finally: + self._block_reentrance = False + + @property + def vmin(self) -> float: + """get or set the lower contrast limit""" + return self._vmin + + @vmin.setter + def vmin(self, value: float): + value = float(value) + if self._block_reentrance or value == self._vmin: + return + self._block_reentrance = True + try: + self._vmin = value + self._update_bar_texture() + for image in self._images: + image.vmin = value + finally: + self._block_reentrance = False + + @property + def vmax(self) -> float: + """get or set the upper contrast limit""" + return self._vmax + + @vmax.setter + def vmax(self, value: float): + value = float(value) + if self._block_reentrance or value == self._vmax: + return + self._block_reentrance = True + try: + self._vmax = value + self._update_bar_texture() + for image in self._images: + image.vmax = value + finally: + self._block_reentrance = False + + @property + def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: + """the histogram as a precomputed (counts, edges) tuple, or ``None`` for no histogram""" + return self._histogram + + @histogram.setter + def histogram(self, value): + if value is None: + self._histogram = None + return + counts, edges = value + counts = np.asarray(counts, dtype=np.float32) + edges = np.asarray(edges, dtype=np.float64) + if edges.shape[0] != counts.shape[0] + 1: + raise ValueError( + "histogram edges must have one more element than counts, you have passed " + f"counts: {counts.shape[0]} and edges: {edges.shape[0]}" + ) + self._histogram = (counts, edges) + + # the histogram defines the value axis + self._data_min = float(edges[0]) + self._data_max = float(edges[-1]) + self._update_bar_texture() + + @property + def data_range(self) -> tuple[float, float]: + """the value range spanned by the bar""" + return (self._data_min, self._data_max) + + @data_range.setter + def data_range(self, value): + self._data_min, self._data_max = self._validate_range(value) + self._update_bar_texture() + + @property + def gamma(self) -> float: + """get or set the gamma, applied to the images and the bar""" + return self._gamma + + @gamma.setter + def gamma(self, value: float): + value = float(value) + if self._block_reentrance or value == self._gamma: + return + self._block_reentrance = True + try: + self._gamma = value + self._update_bar_texture() + for image in self._images: + image.gamma = value + finally: + self._block_reentrance = False + + @property + def bar_width(self) -> int: + """get or set the width of the colored bar in pixels""" + return self._bar_width + + @bar_width.setter + def bar_width(self, value: int): + self._bar_width = int(value) + + @staticmethod + def _validate_range(data_range): + data_min, data_max = float(data_range[0]), float(data_range[1]) + if data_max <= data_min: + raise ValueError( + f"data_range max ({data_max}) must be greater than min ({data_min})" + ) + return data_min, data_max + + def _image_event_handler(self, ev): + """when an image's vmin, vmax, or cmap changes, update this colorbar to match""" + setattr(self, ev.type, ev.info["value"]) + + def _connect_image(self, image): + """subscribe to an image's vmin, vmax and gamma events, and its cmap if it is grayscale""" + events = ["vmin", "vmax", "gamma"] + # rgb(a) images have no cmap feature to listen to + if image.cmap is not None: + events.append("cmap") + image.add_event_handler(self._image_event_handler, *events) + + def _disconnect_images(self, *args): + """disconnect the event handlers of the managed images""" + for image in self._images: + for ev, handlers in image.event_handlers: + if self._image_event_handler in handlers: + image.remove_event_handler(self._image_event_handler, ev) + + def _make_picker_texture(self, name): + lut = (Colormap(name)(np.linspace(0, 1, 256)) * 255).astype(np.uint8) + data = np.ascontiguousarray(np.tile(lut[None, :, :], (2, 1, 1))) + h, w = data.shape[:2] + texture = self._device.create_texture( + size=(w, h, 1), + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=wgpu.TextureFormat.rgba8unorm, + mip_level_count=1, + sample_count=1, + ) + self._device.queue.write_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"offset": 0, "bytes_per_row": w * 4}, + (w, h, 1), + ) + return self._renderer.backend.register_texture(texture.create_view()) + + def _update_bar_texture(self): + if self._bar_texture is None: + # not added to a figure yet, no device + return + # the bar spans the flanked axis so it aligns with the histogram and the handles + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + lo = (self._vmin - axis_min) / span + hi = (self._vmax - axis_min) / span + t = np.linspace(1.0, 0.0, self.LUT_HEIGHT) + norm = np.clip((t - lo) / (hi - lo), 0.0, 1.0) + norm = norm ** self._gamma + colors = (Colormap(self._cmap_name)(norm) * 255).astype(np.uint8) + data = np.ascontiguousarray(np.tile(colors[:, None, :], (1, self.TEX_WIDTH, 1))) + self._device.queue.write_texture( + {"texture": self._bar_texture, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"offset": 0, "bytes_per_row": self.TEX_WIDTH * 4}, + (self.TEX_WIDTH, self.LUT_HEIGHT, 1), + ) + + @property + def _renderer(self): + return self._figure.imgui_renderer + + def _axis_range(self) -> tuple[float, float]: + """the value axis: the data range flanked on each side so handles can move past the data extremes""" + flank = 0.1 * (self._data_max - self._data_min) + return self._data_min - flank, self._data_max + flank + + def update(self): + draw_list = imgui.get_window_draw_list() + avail = imgui.get_content_region_avail() + line_h = imgui.get_text_line_height_with_spacing() + + p0 = imgui.get_cursor_screen_pos() + total_h = avail.y + + bar_w = self._bar_width + # the value axis spans the height minus a line of padding at the top and bottom + bar_y = p0.y + line_h + bar_h = max(50.0, total_h - 2 * line_h) + + # accumulated across the region lines and bar handles to drive the resize cursor + self._hovering_handle = False + + # anchor the bar to the right edge of the window; the histogram and value text sit to its left, + # the handle overhang stays within the window padding + bar_x = p0.x + avail.x - self.HANDLE_OVERHANG - bar_w + + has_hist = self._histogram is not None + if has_hist: + # the histogram has a fixed width (HIST_WIDTH), drawn to the left of the bar + hist_x_right = bar_x - self.HIST_GAP + hist_x_left = hist_x_right - self.HIST_WIDTH + + # histogram line profile, inset so the vmin/vmax fill and lines extend beyond it + self._draw_histogram( + draw_list, + hist_x_left + self.FILL_OVERHANG, + hist_x_right - self.FILL_OVERHANG, + bar_y, + bar_h, + ) + # draggable vmin, vmax lines, shaded region, and value text drawn over the histogram + self._draw_region(hist_x_left, hist_x_right, bar_y, bar_h) + + # the colorbar bar + imgui.set_cursor_screen_pos((bar_x, bar_y)) + imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) + imgui.push_style_var(imgui.StyleVar_.image_border_size, self.BAR_BORDER) + imgui.image(self._bar_tex_id, image_size=(bar_w - 2 * self.BAR_BORDER, bar_h)) + imgui.pop_style_var() + imgui.pop_style_color() + + # right-click for the gamma slider and colormap picker + if imgui.begin_popup_context_window("##colorbar_popup"): + self._draw_popup() + imgui.end_popup() + + # without a histogram the vmin, vmax handles live on the bar itself + if not has_hist: + self._draw_bar_handles(bar_x, bar_y, bar_w, bar_h) + + # show a vertical-resize cursor while hovering any handle + if self._hovering_handle and not self._resize_cursor_set: + self._figure.canvas.set_cursor("ns_resize") + self._resize_cursor_set = True + elif not self._hovering_handle and self._resize_cursor_set: + self._figure.canvas.set_cursor("default") + self._resize_cursor_set = False + + def _value_to_y(self, v, y0, bar_h): + axis_min, axis_max = self._axis_range() + return y0 + (1.0 - (v - axis_min) / (axis_max - axis_min)) * bar_h + + def _y_to_value(self, y, y0, bar_h): + axis_min, axis_max = self._axis_range() + return axis_min + (1.0 - (y - y0) / bar_h) * (axis_max - axis_min) + + def _draw_histogram(self, draw_list, x_left, x_right, bar_y, bar_h): + counts, edges = self._histogram + cmin = counts.min() + cmax = counts.max() + span = cmax - cmin + if span <= 0: + return + + color = imgui.color_convert_float4_to_u32((0.7, 0.7, 0.7, 1.0)) + hist_w = x_right - x_left + if hist_w <= 0: + return + # min count maps to the right edge next to the bar, max count to the left edge, filling the width + norm = (counts - cmin) / span + centers = 0.5 * (edges[:-1] + edges[1:]) + + # frequency increases to the left, away from the bar, value maps to y, drawn as a line profile + points = [ + imgui.ImVec2(x_right - frac * hist_w, self._value_to_y(c, bar_y, bar_h)) + for frac, c in zip(norm, centers) + ] + draw_list.add_polyline(points, color, 1.5, 0) + + def _draw_region(self, x_left, x_right, bar_y, bar_h): + draw_list = imgui.get_window_draw_list() + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + # yellow highlight when a line is hovered/dragged, like the HistogramLUTTool + yellow = imgui.color_convert_float4_to_u32((1.0, 1.0, 0.0, 1.0)) + # dark blue fill, the same color as the HistogramLUTTool LinearRegionSelector + fill_color = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.35, 0.4)) + + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + width = x_right - x_left + grab = self.HANDLE_HEIGHT + min_sep = (grab / bar_h) * span + + def cursor_value(): + # the data value under the cursor. Lines track this absolute position (plus the grab offset) + # rather than accumulating per-frame deltas, so a fast drag past an edge pins the line to the extreme + return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) + + # shaded fill between the vmin and vmax lines + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + draw_list.add_rect_filled((x_left, y_vmax), (x_right, y_vmin), fill_color) + + # drag the region between the lines to move both together + if self._region_drag: + top = y_vmax + grab / 2 + bottom = y_vmin - grab / 2 + if bottom > top: + imgui.set_cursor_screen_pos((x_left, top)) + imgui.invisible_button("##region", (width, bottom - top)) + if imgui.is_item_activated(): + self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + if imgui.is_item_active(): + half = 0.5 * (self._vmax - self._vmin) + center = cursor_value() + self._grab_offset + center = max(axis_min + half, min(axis_max - half, center)) + self.vmin = center - half + self.vmax = center + half + + # each line has a hit-window for hovering/dragging; the line turns yellow when hovered or dragged + for label, attr, lo_fn, hi_fn in ( + ("##vmax_line", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), + ("##vmin_line", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ): + cur = getattr(self, attr) + y = self._value_to_y(cur, bar_y, bar_h) + imgui.set_cursor_screen_pos((x_left, y - grab / 2)) + imgui.invisible_button(label, (width, grab)) + hovered = imgui.is_item_hovered() or imgui.is_item_active() + self._hovering_handle = self._hovering_handle or hovered + if imgui.is_item_activated(): + self._grab_offset = cur - cursor_value() + if imgui.is_item_active(): + setattr(self, attr, max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset))) + y = self._value_to_y(getattr(self, attr), bar_y, bar_h) + draw_list.add_line((x_left, y), (x_right, y), yellow if hovered else white, 2.0) + + # current vmax above its line, vmin below its line + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + self._text_right(draw_list, f"{self._vmax:.4g}", x_right, y_vmax - imgui.get_text_line_height()) + self._text_right(draw_list, f"{self._vmin:.4g}", x_right, y_vmin) + + def _text_right(self, draw_list, text: str, x_right: float, y: float): + """draw text right-aligned so it ends at x_right""" + tw = imgui.calc_text_size(text).x + draw_list.add_text((x_right - tw, y), imgui.get_color_u32(imgui.Col_.text), text) + + def _draw_bar_handles(self, bar_x, bar_y, bar_w, bar_h): + draw_list = imgui.get_window_draw_list() + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + # yellow highlight when a handle is hovered/dragged, like the region lines + yellow = imgui.color_convert_float4_to_u32((1.0, 1.0, 0.0, 1.0)) + outline = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.0, 1.0)) + text_color = imgui.get_color_u32(imgui.Col_.text) + + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + h = self.HANDLE_HEIGHT + # the handles extend past the bar on each side + x_left = bar_x - self.HANDLE_OVERHANG + x_right = bar_x + bar_w + self.HANDLE_OVERHANG + min_sep = (h / bar_h) * span + + def cursor_value(): + return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) + + # thin reference lines at the data min and max, so the flank beyond the data range is visible + ref = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + for v in (self._data_min, self._data_max): + y = self._value_to_y(v, bar_y, bar_h) + draw_list.add_line((x_left, y), (x_right, y), ref, 1.0) + + # drag the region between the handles to move vmin and vmax together + if self._region_drag: + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + top = y_vmax + h / 2 + bottom = y_vmin - h / 2 + if bottom > top: + imgui.set_cursor_screen_pos((x_left, top)) + imgui.invisible_button("##bar_region", (x_right - x_left, bottom - top)) + if imgui.is_item_activated(): + self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + if imgui.is_item_active(): + half = 0.5 * (self._vmax - self._vmin) + center = cursor_value() + self._grab_offset + center = max(axis_min + half, min(axis_max - half, center)) + self.vmin = center - half + self.vmax = center + half + + for label, attr, lo_fn, hi_fn in ( + ("##bar_vmax", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), + ("##bar_vmin", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ): + cur = getattr(self, attr) + y = self._value_to_y(cur, bar_y, bar_h) + imgui.set_cursor_screen_pos((x_left, y - h / 2)) + imgui.invisible_button(label, (x_right - x_left, h)) + hovered = imgui.is_item_hovered() or imgui.is_item_active() + self._hovering_handle = self._hovering_handle or hovered + if imgui.is_item_activated(): + self._grab_offset = cur - cursor_value() + if imgui.is_item_active(): + setattr(self, attr, max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset))) + y = self._value_to_y(getattr(self, attr), bar_y, bar_h) + + draw_list.add_rect_filled((x_left, y - h / 2), (x_right, y + h / 2), yellow if hovered else white) + draw_list.add_rect((x_left, y - h / 2), (x_right, y + h / 2), outline, thickness=1.0) + + # current value to the left of the bar, vmax above its handle and vmin below + text = f"{getattr(self, attr):.4g}" + ty = y - imgui.get_text_line_height() if attr == "vmax" else y + tw = imgui.calc_text_size(text).x + draw_list.add_text((x_left - 3 - tw, ty), text_color, text) + + def _draw_popup(self): + imgui.set_next_item_width(150) + changed, gamma = imgui.slider_float("gamma", self._gamma, 0.1, 5.0) + if changed: + self.gamma = gamma + + # reset vmin, vmax using the data of each image + if imgui.menu_item("Reset vmin-vmax", "", False)[0]: + for image in self._images: + image.reset_vmin_vmax() + + # reset gamma to 1.0 + if imgui.menu_item("Reset gamma", "", False)[0]: + self.gamma = 1.0 + + texture_height = imgui.get_font_size() - 2 + + # colormaps grouped by category, qualitative colormaps are not useful for a continuous colorbar + for category, names in COLORMAP_NAMES.items(): + if category == "qualitative": + continue + + imgui.separator() + imgui.text(category.capitalize()) + + for name in names: + imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) + imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0) + imgui.image(self._picker_tex_ids[name], image_size=(75, texture_height)) + imgui.pop_style_var() + imgui.pop_style_color() + + imgui.same_line() + + clicked, selected = imgui.selectable(name, p_selected=(name == self._cmap_name)) + if clicked and selected: + self.cmap = name diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index 435de4206..4c1bd289a 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -1,20 +1,18 @@ from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx -from ..layouts._subplot import Subplot -from ._base import Window +from ._base import ImguiWindow from ..layouts._utils import IMGUI_TOOLBAR_HEIGHT -class SubplotToolbar(Window): - def __init__(self, subplot: Subplot): +class SubplotToolbar(ImguiWindow): + def __init__(self): """ - Subplot toolbar shown below all subplots + Subplot toolbar shown below all subplots. The subplot is provided via ``_fpl_add_hook()`` when the + toolbar is added to the subplot. """ super().__init__() - self._subplot = subplot - - def update(self): + def draw(self): # get subplot rect x, y, width, height = self._subplot.frame.rect @@ -27,12 +25,26 @@ def update(self): imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_background + # stay behind floating and fixed overlays so they remain accessible when drawn over the toolbar + | imgui.WindowFlags_.no_bring_to_front_on_focus ) imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags) # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) + + # draw the toolbar and any appended imgui elements + for update_call in self._update_calls: + update_call() + + # pop id when all UI has been written to window + imgui.pop_id() + + # end window + imgui.end() + + def update(self): with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): @@ -59,9 +71,3 @@ def update(self): ) if imgui.is_item_hovered(0): imgui.set_tooltip("maintain aspect") - - # pop id when all UI has been written to window - imgui.pop_id() - - # end window - imgui.end() diff --git a/fastplotlib/ui/_utils.py b/fastplotlib/ui/_utils.py new file mode 100644 index 000000000..32c6f9f68 --- /dev/null +++ b/fastplotlib/ui/_utils.py @@ -0,0 +1,46 @@ +class ChangeFlag: + """ + A flag that helps detect whether an imgui UI has been changed by the user. + Basically, once True, always True. + + Example:: + + changed = ChangeFlag(False) + + changed.value, bah = (False, False) + + print(changed.value) + + changed.value, bah = (True, False) + + print(changed.value) + + changed.value, bah = (False, False) + + print(changed.value) + + """ + + def __init__(self, value: bool): + self._value = bool(value) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool): + if value: + self._value = True + + def __bool__(self): + return self.value + + def __or__(self, other): + return self._value | other + + def __eq__(self, other): + return self.value == other + + def force_value(self, value): + self._value = value diff --git a/fastplotlib/ui/right_click_menus/__init__.py b/fastplotlib/ui/right_click_menus/__init__.py index 6ccc50646..a32b87263 100644 --- a/fastplotlib/ui/right_click_menus/__init__.py +++ b/fastplotlib/ui/right_click_menus/__init__.py @@ -1,2 +1 @@ -from ._colormap_picker import ColormapPicker from ._standard_menu import StandardRightClickMenu diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py deleted file mode 100644 index 9df26dcdc..000000000 --- a/fastplotlib/ui/right_click_menus/_colormap_picker.py +++ /dev/null @@ -1,176 +0,0 @@ -import ctypes - -import numpy as np -import cmap - -import wgpu -from imgui_bundle import imgui -from wgpu import GPUTexture - -from .. import Popup -from ...utils.functions import ( - COLORMAP_NAMES, - SEQUENTIAL_CMAPS, - CYCLIC_CMAPS, - DIVERGING_CMAPS, - MISC_CMAPS, -) - -all_cmaps = [*SEQUENTIAL_CMAPS, *CYCLIC_CMAPS, *DIVERGING_CMAPS, *MISC_CMAPS] - - -class ColormapPicker(Popup): - """Colormap picker menu popup tool""" - - # name used to trigger this popup after it has been registered with a Figure - name = "colormap-picker" - - def __init__(self, figure): - super().__init__(figure=figure) - - self.renderer = self._figure.renderer - self.imgui_renderer = self._figure.imgui_renderer - - # maps str cmap names -> int texture IDs - self._cmap_texture_refs: dict[str, imgui.ImTextureRef] = dict() - - # make all colormaps and upload representative texture for each cmap to the GPU - for name in all_cmaps: - # get data that represents cmap - colormap = cmap.Colormap(name) - data = colormap(np.linspace(0, 1)) * 255 - - # needs to be 2D to create a texture - data = np.vstack([[data]] * 2).astype(np.uint8) - - # upload the texture to the GPU, get the texture ID and texture - self._cmap_texture_refs[name] = self._create_texture_and_upload(data) - - # used to set the states of the UI - self._lut_tool = None - self._pos: tuple[int, int] = -1, -1 - self._open_new: bool = False - - self.is_open = False - - self._popup_state = "never-opened" - - self._texture_height = None - - def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture]: - """crates a GPUTexture from the 2D data and uploads it""" - - # create a GPUTexture - texture = self.renderer.device.create_texture( - size=(data.shape[1], data.shape[0], 4), - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, - dimension=wgpu.TextureDimension.d2, - format=wgpu.TextureFormat.rgba8unorm, - mip_level_count=1, - sample_count=1, - ) - - # upload to the GPU - self.renderer.device.queue.write_texture( - {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, - data, - {"offset": 0, "bytes_per_row": data.shape[1] * 4}, - (data.shape[1], data.shape[0], 1), - ) - - # get a view - texture_view = texture.create_view() - - # return texture ref - return self.imgui_renderer.backend.register_texture(texture_view) - - def open(self, pos: tuple[int, int], lut_tool): - """ - Request that the popup be opened on the next render cycle - - Parameters - ---------- - pos: int, int - (x, y) position - - lut_tool: HistogramLUTTool - instance of the LUT tool - - Returns - ------- - - """ - self._lut_tool = lut_tool - - self._pos = pos - - self._open_new = True - - def close(self): - """cleanup after popup has closed""" - self._lut_tool = None - self._open_new = False - self._pos = -1, -1 - - self.is_open = False - - def _add_cmap_menu_item(self, cmap_name: str): - # white border around cmap image - imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) - imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0) - - # cmap image - texture_ref = self._cmap_texture_refs[cmap_name] - imgui.image( - texture_ref, - image_size=(50, self._texture_height), - ) - # pop white border - imgui.pop_style_var() - imgui.pop_style_color() - - imgui.same_line() - - clicked, selected = imgui.selectable( - label=cmap_name, - p_selected=cmap_name == self._lut_tool.cmap, - ) - - if clicked and selected: - self._lut_tool.cmap = cmap_name - - def update(self): - if self._open_new: - # new popup has been triggered by a LUT tool - self._open_new = False - - imgui.set_next_window_pos(self._pos) - imgui.open_popup("cmap-picker") - - if imgui.begin_popup("cmap-picker"): - self.is_open = True - - # make the cmap image height the same as the text height - self._texture_height = (imgui.get_font_size()) - 2 - - if imgui.menu_item("Reset vmin-vmax", "", False)[0]: - for image in self._lut_tool.images: - image.reset_vmin_vmax() - - # add all the cmap options - for cmap_type in COLORMAP_NAMES.keys(): - if cmap_type == "qualitative": - continue - - imgui.separator() - imgui.text(cmap_type.capitalize()) - - for cmap_name in COLORMAP_NAMES[cmap_type]: - self._add_cmap_menu_item(cmap_name) - - imgui.end_popup() - - else: - # popup went from open to closed - if self.is_open == True: - self.close() diff --git a/fastplotlib/ui/right_click_menus/_image_adjust.py b/fastplotlib/ui/right_click_menus/_image_adjust.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index 9c659f4a7..d5a25bca4 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -2,7 +2,7 @@ from ...layouts._utils import controller_types from ...layouts._plot_area import PlotArea -from ...ui import Popup +from ...ui import ImguiPopup def flip_axis(subplot: PlotArea, axis: str, flip: bool): @@ -19,147 +19,84 @@ def flip_axis(subplot: PlotArea, axis: str, flip: bool): setattr(camera.local, axis_attr, scale * -1) -class StandardRightClickMenu(Popup): +class StandardRightClickMenu(ImguiPopup): """Right click menu that is shown on subplots""" - def __init__(self, figure): - super().__init__(figure=figure) - - self._last_right_click_pos = None - self._mouse_down: bool = False - - # whether the right click menu is currently open or not - self.is_open: bool = False + def __init__(self): + super().__init__() + # the subplot whose controller window is open, False if no controller window is open self._controller_window_open: bool | PlotArea = False - def get_subplot(self) -> PlotArea | bool | None: - """get the subplot that a click occurred in""" - if self._last_right_click_pos is None: - return False - - for subplot in self._figure: - if subplot.viewport.is_inside(*self._last_right_click_pos): - return subplot - - # not inside a subplot - return False - - def cleanup(self): - """called when the popup disappears""" - self.is_open = False - - def _extra_menu(self): - # extra menu items, optional, implement in subclass - pass - def update(self): - if imgui.is_mouse_down(1) and not self._mouse_down: - # mouse button was pressed down, store this position - self._mouse_down = True - self._last_right_click_pos = imgui.get_mouse_pos() - - if imgui.is_mouse_released(1) and self._mouse_down: - self._mouse_down = False - - # open popup only if mouse was not moved between mouse_down and mouse_up events - if self._last_right_click_pos == imgui.get_mouse_pos(): - if self.get_subplot() is not False: # must explicitly check for False - # open only if right click was inside a subplot - imgui.open_popup(f"right-click-menu") - - # TODO: call this just once when going from open -> closed state - if not imgui.is_popup_open("right-click-menu"): - self.cleanup() - - if imgui.begin_popup(f"right-click-menu"): - if self.get_subplot() is False: # must explicitly check for False - # for some reason it will still trigger at certain locations - # despite open_popup() only being called when an actual - # subplot is returned - imgui.end_popup() - imgui.close_current_popup() - self.cleanup() - return - - name = self.get_subplot().name - - if name is not None: - # text label at the top of the menu - imgui.text(f"subplot: {name}") - imgui.separator() - - _, show_fps = imgui.menu_item( - "Show fps", "", self.get_subplot().get_figure().imgui_show_fps - ) - self.get_subplot().get_figure().imgui_show_fps = show_fps + subplot = self.subplot - # autoscale, center, maintain aspect - if imgui.menu_item(f"Autoscale", "", False)[0]: - self.get_subplot().auto_scale() + if subplot.name is not None: + # text label at the top of the menu + imgui.text(f"subplot: {subplot.name}") + imgui.separator() - if imgui.menu_item(f"Center", "", False)[0]: - self.get_subplot().center_scene() + _, show_fps = imgui.menu_item("Show fps", "", self._figure.imgui_show_fps) + self._figure.imgui_show_fps = show_fps - _, maintain_aspect = imgui.menu_item( - "Maintain Aspect", "", self.get_subplot().camera.maintain_aspect - ) - self.get_subplot().camera.maintain_aspect = maintain_aspect + # autoscale, center, maintain aspect + if imgui.menu_item("Autoscale", "", False)[0]: + subplot.auto_scale() - imgui.separator() + if imgui.menu_item("Center", "", False)[0]: + subplot.center_scene() - # toggles to flip axes cameras - for axis in ["x", "y", "z"]: - scale = getattr(self.get_subplot().camera.local, f"scale_{axis}") - changed, flip = imgui.menu_item( - f"Flip {axis} axis", "", bool(scale < 0) - ) + _, maintain_aspect = imgui.menu_item( + "Maintain Aspect", "", subplot.camera.maintain_aspect + ) + subplot.camera.maintain_aspect = maintain_aspect - if changed: - flip_axis(self.get_subplot(), axis, flip) + imgui.separator() - imgui.separator() + # toggles to flip axes cameras + for axis in ["x", "y", "z"]: + scale = getattr(subplot.camera.local, f"scale_{axis}") + changed, flip = imgui.menu_item(f"Flip {axis} axis", "", bool(scale < 0)) - # toggles to show/hide the grid - for plane in ["xy", "xz", "yz"]: - grid = getattr(self.get_subplot().axes.grids, plane) - visible = grid.visible - changed, new_visible = imgui.menu_item(f"Grid {plane}", "", visible) + if changed: + flip_axis(subplot, axis, flip) - if changed: - grid.visible = new_visible + imgui.separator() - imgui.separator() + # toggles to show/hide the grid + for plane in ["xy", "xz", "yz"]: + grid = getattr(subplot.axes.grids, plane) + changed, visible = imgui.menu_item(f"Grid {plane}", "", grid.visible) - # camera FOV - changed, fov = imgui.slider_float( - "FOV", v=self.get_subplot().camera.fov, v_min=0.0, v_max=180.0 - ) + if changed: + grid.visible = visible - imgui.separator() + imgui.separator() - if changed: - # FOV between 0 and 1 is numerically unstable - if 0 < fov < 1: - fov = 1 + # camera FOV + changed, fov = imgui.slider_float( + "FOV", v=subplot.camera.fov, v_min=0.0, v_max=180.0 + ) - # need to update FOV via controller, if FOV is directly set - # on the camera the controller will immediately set it back - self.get_subplot().controller.update_fov( - fov - self.get_subplot().camera.fov, - animate=False, - ) + if changed: + # FOV between 0 and 1 is numerically unstable + if 0 < fov < 1: + fov = 1 - imgui.separator() + # need to update FOV via controller, if FOV is directly set + # on the camera the controller will immediately set it back + subplot.controller.update_fov(fov - subplot.camera.fov, animate=False) - # controller options - if imgui.menu_item("Controller Options", "", False)[0]: - self._controller_window_open = self.get_subplot() + imgui.separator() - self._extra_menu() + # controller options + if imgui.menu_item("Controller Options", "", False)[0]: + self._controller_window_open = subplot - imgui.end_popup() + def draw(self): + super().draw() + # the controller window is not part of the popup, it stays open after the popup closes if self._controller_window_open: self._draw_controller_window() diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index d404decf9..c5caa3845 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -8,6 +8,5 @@ NDImageProcessor, NDImage, ) -from .image_widget import ImageWidget -__all__ = ["NDWidget", "ImageWidget"] +__all__ = ["NDWidget"] diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 951fc5a55..3090e14c7 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -15,7 +15,7 @@ enums, ) from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic -from ...tools import HistogramLUTTool +from ...ui import ImguiColorbar from ._base import ( NDProcessor, NDGraphic, @@ -318,7 +318,7 @@ def __init__( Wraps an :class:`NDImageProcessor` and manages either an ``ImageGraphic`` or``ImageVolumeGraphic``. swaps automatically when :attr:`spatial_dims` is reassigned at runtime. Also - owns a ``HistogramLUTTool`` for interactive vmin, vmax adjustment. + owns an ``ImguiColorbar`` for interactive vmin, vmax adjustment. Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the @@ -361,7 +361,7 @@ def __init__( See :class:`NDProcessor`. compute_histogram : bool, default ``True`` - Whether to initialize the ``HistogramLUTTool``. + Whether to initialize the ``ImguiColorbar``. slider_dim_transforms : dict, optional See :class:`NDProcessor`. @@ -402,7 +402,7 @@ def __init__( self._colorrange = colorrange self._graphic: ImageGraphic | ImageYUVGraphic | None = None - self._histogram_widget: HistogramLUTTool | None = None + self._histogram_widget: ImguiColorbar | None = None # create a graphic run_sync(self._create_graphic()) @@ -484,27 +484,29 @@ def _reset_histogram(self): if self.graphic is None: return + subplot = self._nd_subplot.subplot + if not self.processor.compute_histogram: - # hide right dock if histogram not desired - self._nd_subplot.subplot.docks["right"].size = 0 + # remove the colorbar from the right edge if a histogram is not desired + if self._histogram_widget is not None: + subplot.remove_imgui_window("right") + self._histogram_widget = None return if self.processor.histogram: - if self._histogram_widget: - # histogram widget exists, update it + if self._histogram_widget is not None: + # colorbar widget exists, update it and rebind to the current graphic self._histogram_widget.histogram = self.processor.histogram self._histogram_widget.images = self.graphic - if self._nd_subplot.subplot.docks["right"].size < 1: - self._nd_subplot.subplot.docks["right"].size = 80 else: - # make hist tool - self._histogram_widget = HistogramLUTTool( - histogram=self.processor.histogram, + # make the colorbar, it reserves space on the subplot's right edge + self._histogram_widget = ImguiColorbar( images=self.graphic, - name=f"hist-{hex(id(self.graphic))}", + histogram=self.processor.histogram, + ) + subplot.add_imgui_window( + self._histogram_widget, location="right", size=100 ) - self._nd_subplot.subplot.docks["right"].add_graphic(self._histogram_widget) - self._nd_subplot.subplot.docks["right"].size = 80 self.graphic.reset_vmin_vmax() @@ -571,7 +573,7 @@ async def _set_indices_(self, indices: dict[str, Any] = None): @property def compute_histogram(self) -> bool: - """whether or not to compute the histogram and display the HistogramLUTTool""" + """whether or not to compute the histogram and display the ImguiColorbar""" return self.processor.compute_histogram @compute_histogram.setter @@ -580,8 +582,8 @@ def compute_histogram(self, v: bool): self._reset_histogram() @property - def histogram_widget(self) -> HistogramLUTTool: - """The histogram lut tool associated with this NDGraphic""" + def histogram_widget(self) -> ImguiColorbar: + """The colorbar associated with this NDGraphic""" return self._histogram_widget @property diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 1804986a1..c5b6f58c0 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -19,8 +19,8 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe self._indices._add_ndwidget_(self) - self._figure = ImguiFigure(std_right_click_menu=RightClickMenu, **kwargs) - self._figure.std_right_click_menu.set_nd_widget(self) + self._figure = ImguiFigure(**kwargs) + self._figure.set_imgui_right_click(RightClickMenu(self)) self._subplots_nd: dict[Subplot, NDWSubplot] = dict() for subplot in self.figure: @@ -29,8 +29,10 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe # hard code the expected height so that the first render looks right in tests, docs etc. ui_size = 57 + (50 * len(self.indices)) - self._sliders_ui = NDWidgetUI(self.figure, ui_size, self) - self.figure.add_gui(self._sliders_ui) + self._sliders_ui = NDWidgetUI(self) + self.figure.add_imgui_window( + self._sliders_ui, location="bottom", size=ui_size, title="NDWidget controls" + ) @property def figure(self) -> ImguiFigure: diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index ae9296567..3a54d327a 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -14,7 +14,7 @@ ) from ...utils import quick_min_max from ...layouts import Subplot -from ...ui import EdgeWindow, StandardRightClickMenu +from ...ui import ImguiWindow, StandardRightClickMenu from ._index import RangeContinuous from ._base import NDGraphic from ._nd_positions import NDPositions, NDTimeseries @@ -23,17 +23,9 @@ position_graphic_types = [ScatterCollection, ScatterStack, LineCollection, LineStack] -class NDWidgetUI(EdgeWindow): - def __init__(self, figure, size, ndwidget): - super().__init__( - figure=figure, - size=size, - title="NDWidget controls", - location="bottom", - window_flags=imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize - | imgui.WindowFlags_.no_title_bar, - ) +class NDWidgetUI(ImguiWindow): + def __init__(self, ndwidget): + super().__init__() self._ndwidget = ndwidget # whether or not a dimension is in play mode @@ -203,22 +195,17 @@ def update(self): class RightClickMenu(StandardRightClickMenu): - def __init__(self, figure): - self._ndwidget = None - self._ndgraphic_windows = set() - - super().__init__(figure=figure) + def __init__(self, ndwidget): + super().__init__() - def set_nd_widget(self, ndw): - self._ndwidget = ndw + self._ndwidget = ndwidget + self._ndgraphic_windows = set() - def _extra_menu(self): - if self._ndwidget is None: - return + def update(self): + super().update() if imgui.begin_menu("ND Graphics"): - subplot = self.get_subplot() - for ndg in self._ndwidget[subplot].nd_graphics: + for ndg in self._ndwidget[self.subplot].nd_graphics: name = ndg.name if ndg.name is not None else hex(id(ndg)) if imgui.menu_item( f"{name}", "", False @@ -227,9 +214,10 @@ def _extra_menu(self): imgui.end_menu() - def update(self): - super().update() + def draw(self): + super().draw() + # the ND graphic windows are not part of the popup, they stay open after the popup closes for ndg in list(self._ndgraphic_windows): # set -> list so we can change size during iteration name = ndg.name if ndg.name is not None else hex(id(ndg)) subplot = ndg.graphic._plot_area diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index c5a526e93..336d82b25 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -33,6 +33,7 @@ def generate_add_graphics_methods(): f.write("from typing import *\n\n") f.write("import numpy\n\n") + f.write("from numpy.typing import NDArray\n\n") f.write("import pygfx\n\n") f.write("from ..graphics import *\n") f.write("from ..graphics._base import Graphic\n")