diff --git a/geemap/core.py b/geemap/core.py index 62027a3091..fb5599901b 100644 --- a/geemap/core.py +++ b/geemap/core.py @@ -673,8 +673,14 @@ def _layer_manager(self) -> Optional[map_widgets.LayerManager]: Optional[map_widgets.LayerManager]: The layer manager widget if found, else None. """ if toolbar_widget := self._toolbar: - if isinstance(toolbar_widget.accessory_widget, map_widgets.LayerManager): - return toolbar_widget.accessory_widget + return next( + ( + widget + for widget in toolbar_widget.accessory_widgets + if isinstance(widget, map_widgets.LayerManager) + ), + None, + ) return self._find_widget_of_type(map_widgets.LayerManager) @property @@ -917,25 +923,6 @@ def add(self, obj: Any, position: str = "", **kwargs: Any) -> None: else: super().add(obj) - def _on_toggle_toolbar_layers(self, is_open: bool) -> None: - """Handles the toggle event for the toolbar layers. - - Args: - is_open (bool): Whether the toolbar layers are open. - """ - if is_open: - if self._layer_manager: - return - - layer_manager = map_widgets.LayerManager(self) - layer_manager.header_hidden = True - layer_manager.close_button_hidden = True - layer_manager.refresh_layers() - self._toolbar.accessory_widget = layer_manager - else: - self._toolbar.accessory_widget = None - self.remove("layer_manager") - def _add_layer_manager(self, position: str, **kwargs: Any) -> None: """Adds a layer manager to the map. @@ -943,9 +930,6 @@ def _add_layer_manager(self, position: str, **kwargs: Any) -> None: position (str): The position to place the layer manager. **kwargs (Any): Additional keyword arguments. """ - if self._layer_manager: - return - layer_manager = map_widgets.LayerManager(self, **kwargs) layer_manager.on_close = lambda: self.remove("layer_manager") layer_manager.refresh_layers() @@ -964,16 +948,21 @@ def _add_toolbar(self, position: str, **kwargs: Any) -> None: if self._toolbar: return + layer_manager = map_widgets.LayerManager(self) + layer_manager.header_hidden = True + layer_manager.close_button_hidden = True + layer_manager.refresh_layers() + toolbar_val = toolbar.Toolbar( - self, self._toolbar_main_tools(), self._toolbar_extra_tools(), **kwargs + self, + self._toolbar_main_tools(), + self._toolbar_extra_tools(), + [layer_manager], ) - toolbar_val.on_layers_toggled = self._on_toggle_toolbar_layers toolbar_control = ipyleaflet.WidgetControl( widget=toolbar_val, position=position ) super().add(toolbar_control) - # Enable the layer manager by default. - toolbar_val.toggle_layers(True) def _add_inspector(self, position: str, **kwargs: Any) -> None: """Adds an inspector to the map. @@ -1255,65 +1244,66 @@ def _add_colorbar( return control def _open_help_page( - self, host_map: "MapInterface", selected: bool, item: toolbar.Toolbar.Item + self, host_map: "MapInterface", selected: bool, item: toolbar.ToolbarItem ) -> None: """Opens the help page. Args: host_map (MapInterface): The host map. selected (bool): Whether the item is selected. - item (toolbar.Toolbar.Item): The toolbar item. + item (toolbar.ToolbarItem): The toolbar item. """ del host_map, item # Unused. if selected: coreutils.open_url("https://geemap.org") - def _toolbar_main_tools(self) -> List[toolbar.Toolbar.Item]: + def _toolbar_main_tools(self) -> List[toolbar.ToolbarItem]: """Gets the main tools for the toolbar. Returns: - List[toolbar.Toolbar.Item]: The main tools for the toolbar. + List[toolbar.ToolbarItem]: The main tools for the toolbar. """ @toolbar._cleanup_toolbar_item def inspector_tool_callback( - map: Map, selected: bool, item: toolbar.Toolbar.Item + map: Map, selected: bool, item: toolbar.ToolbarItem ): del selected, item # Unused. map.add("inspector") return map._inspector @toolbar._cleanup_toolbar_item - def basemap_tool_callback(map: Map, selected: bool, item: toolbar.Toolbar.Item): + def basemap_tool_callback(map: Map, selected: bool, item: toolbar.ToolbarItem): del selected, item # Unused. map.add("basemap_selector") return map._basemap_selector return [ - toolbar.Toolbar.Item( + toolbar.ToolbarItem( icon="map", tooltip="Basemap selector", callback=basemap_tool_callback, - reset=False, ), - toolbar.Toolbar.Item( + toolbar.ToolbarItem( icon="info", tooltip="Inspector", callback=inspector_tool_callback, - reset=False, ), - toolbar.Toolbar.Item( - icon="question", tooltip="Get help", callback=self._open_help_page + toolbar.ToolbarItem( + icon="question_mark", + tooltip="Get help", + callback=self._open_help_page, + reset=True, ), ] - def _toolbar_extra_tools(self) -> Optional[List[toolbar.Toolbar.Item]]: + def _toolbar_extra_tools(self) -> List[toolbar.ToolbarItem]: """Gets the extra tools for the toolbar. Returns: - Optional[List[toolbar.Toolbar.Item]]: The extra tools for the toolbar. + List[toolbar.ToolbarItem]: The extra tools for the toolbar. """ - return None + return [] def _control_config(self) -> Dict[str, List[str]]: """Gets the control configuration. diff --git a/geemap/toolbar.py b/geemap/toolbar.py index 0415ddf1ef..dda9d6f9e8 100644 --- a/geemap/toolbar.py +++ b/geemap/toolbar.py @@ -9,12 +9,16 @@ # *******************************************************************************# import os +import pathlib from dataclasses import dataclass + +import anywidget import ee import ipyevents import ipyleaflet import ipywidgets as widgets +import traitlets from ipyfilechooser import FileChooser from IPython.core.display import display @@ -27,211 +31,137 @@ @map_widgets.Theme.apply -class Toolbar(widgets.VBox): - """A toolbar that can be added to the map.""" - - @dataclass - class Item: - """A representation of an item in the toolbar. +class ToolbarItem(anywidget.AnyWidget): + """A toolbar item widget for geemap.""" + + _esm = pathlib.Path(__file__).parent / "static" / "toolbar_item.js" + active = traitlets.Bool(False).tag(sync=True) + icon = traitlets.Unicode("").tag(sync=True) + # Unfortunately, "tooltip" is a property already defined on ipywidgets. + tooltip_text = traitlets.Unicode("").tag(sync=True) + + def __init__( + self, + icon: str, + tooltip: str, + callback: Callable[[Any, bool, Any], None], + control: Optional[widgets.Widget] = None, + reset=False, + active=False, + ): + """A togglable, toolbar item. - Attributes: - icon: The icon to use for the item, from https://fontawesome.com/icons. + Args: + icon (str): The icon name to use, from https://fonts.google.com/icons. tooltip: The tooltip text to show a user on hover. callback: A callback function to execute when the item icon is clicked. Its signature should be `callback(map, selected, item)`, where `map` is the host map, `selected` is a boolean indicating if the user selected or unselected the tool, and `item` is this object. - reset: Whether to reset the selection after the callback has finished. control: The control widget associated with this item. Used to cleanup state when toggled off. - toggle_button: The toggle button controlling the item. + reset: Whether to reset the selection after the callback has finished. + active: Whether the tool is currently active. """ + super().__init__() + self.icon = icon + self.tooltip_text = tooltip + self.callback = callback + self.callback_wrapper = lambda *args: None + self.control = control + self.reset = reset + self.active = active + + def toggle_off(self): + if self.active: + self.active = False + + @traitlets.observe("active") + def _observe_value(self, change: Dict[str, Any]) -> None: + if (value := change.get("new")) is not None: + self.callback_wrapper(self.callback, value, self) + if self.active and self.reset: + self.active = False - icon: str - tooltip: str - callback: Callable[[Any, bool, Any], None] - reset: bool = True - control: Optional[widgets.Widget] = None - toggle_button: Optional[widgets.ToggleButton] = None - - def toggle_off(self): - if self.toggle_button: - self.toggle_button.value = False - ICON_WIDTH = "32px" - ICON_HEIGHT = "32px" - NUM_COLS = 3 - - _TOGGLE_TOOL_EXPAND_ICON = "plus" - _TOGGLE_TOOL_EXPAND_TOOLTIP = "Expand toolbar" - _TOGGLE_TOOL_COLLAPSE_ICON = "minus" - _TOGGLE_TOOL_COLLAPSE_TOOLTIP = "Collapse toolbar" +@map_widgets.Theme.apply +class Toolbar(anywidget.AnyWidget): + """A toolbar that can be added to the map.""" - def __init__(self, host_map, main_tools, extra_tools=None): + _esm = pathlib.Path(__file__).parent / "static" / "toolbar.js" + + # The accessory widget. + accessory_widgets = map_widgets.TypedTuple( + trait=traitlets.Instance(widgets.Widget), + help="The accessory widget", + ).tag(sync=True, **widgets.widget_serialization) + + # The list of main tools. + main_tools = map_widgets.TypedTuple( + trait=traitlets.Instance(widgets.Widget), + help="List of main tools", + ).tag(sync=True, **widgets.widget_serialization) + + # The list of extra tools. + extra_tools = map_widgets.TypedTuple( + trait=traitlets.Instance(widgets.Widget), + help="List of extra tools", + ).tag(sync=True, **widgets.widget_serialization) + + # Whether the toolbar is expanded. + expanded = traitlets.Bool(False).tag(sync=True) + + # The currently selected tab. + tab_index = traitlets.Int(0).tag(sync=True) + + _TOGGLE_EXPAND_ICON = "add" + _TOGGLE_EXPAND_TOOLTIP = "Expand toolbar" + _TOGGLE_COLLAPSE_ICON = "remove" + _TOGGLE_COLLAPSE_TOOLTIP = "Collapse toolbar" + + def __init__( + self, + host_map: "geemap.Map", + main_tools: List[ToolbarItem], + extra_tools: List[ToolbarItem], + accessory_widgets: List[widgets.DOMWidget], + ): """Adds a toolbar with `main_tools` and `extra_tools` to the `host_map`.""" + super().__init__() if not main_tools: raise ValueError("A toolbar cannot be initialized without `main_tools`.") self.host_map = host_map - self.toggle_tool = Toolbar.Item( - icon=self._TOGGLE_TOOL_EXPAND_ICON, - tooltip=self._TOGGLE_TOOL_EXPAND_TOOLTIP, + self.toggle_widget = ToolbarItem( + icon=self._TOGGLE_EXPAND_ICON, + tooltip=self._TOGGLE_EXPAND_TOOLTIP, callback=self._toggle_callback, + reset=True, ) - self.on_layers_toggled = None - self._accessory_widget = None - - if extra_tools: - all_tools = main_tools + [self.toggle_tool] + extra_tools - else: - all_tools = main_tools - icons = [tool.icon for tool in all_tools] - tooltips = [tool.tooltip for tool in all_tools] - callbacks = [tool.callback for tool in all_tools] - resets = [tool.reset for tool in all_tools] - self.num_collapsed_tools = len(main_tools) + (1 if extra_tools else 0) - # -(-a//b) is the same as math.ceil(a/b) - self.num_rows_expanded = -(-len(all_tools) // self.NUM_COLS) - self.num_rows_collapsed = -(-self.num_collapsed_tools // self.NUM_COLS) - - self.all_widgets = [ - widgets.ToggleButton( - layout=widgets.Layout( - width="auto", height="auto", padding="0px 0px 0px 4px" - ), - button_style="primary", - icon=icons[i], - tooltip=tooltips[i], - ) - for i in range(len(all_tools)) - ] - self.toggle_widget = self.all_widgets[len(main_tools)] if extra_tools else None - - # We start with a collapsed grid of just the main tools and the toggle one. - self.grid = widgets.GridBox( - children=self.all_widgets[: self.num_collapsed_tools], - layout=widgets.Layout( - width="109px", - grid_template_columns=(self.ICON_WIDTH + " ") * self.NUM_COLS, - grid_template_rows=(self.ICON_HEIGHT + " ") * self.num_rows_collapsed, - grid_gap="1px 1px", - padding="5px", - ), - ) - - def curry_callback(callback, should_reset_after, widget, item): - def returned_callback(change): - if change["type"] != "change": - return - callback(self.host_map, change["new"], item) - if should_reset_after: - widget.value = False - - return returned_callback - - for id, widget in enumerate(self.all_widgets): - all_tools[id].toggle_button = widget - widget.observe( - curry_callback(callbacks[id], resets[id], widget, all_tools[id]), - "value", - ) - - self.toolbar_button = widgets.ToggleButton( - value=False, - tooltip="Toolbar", - icon="wrench", - layout=widgets.Layout( - width="28px", height="28px", padding="0px 0px 0px 4px" - ), - ) - - self.layers_button = widgets.ToggleButton( - value=False, - tooltip="Layers", - icon="server", - layout=widgets.Layout(height="28px", width="72px"), - ) - - self.toolbar_header = widgets.HBox( - layout=widgets.Layout( - display="flex", justify_content="flex-end", align_items="center" + self.main_tools = main_tools + ([self.toggle_widget] if extra_tools else []) + self.extra_tools = extra_tools + for widget in self.main_tools + self.extra_tools: + widget.callback_wrapper = lambda callback, value, tool: callback( + self.host_map, value, tool ) - ) - self.toolbar_header.children = [self.layers_button, self.toolbar_button] - self.toolbar_footer = widgets.VBox() - self.toolbar_footer.children = [self.grid] - - self.toolbar_button.observe(self._toolbar_btn_click, "value") - self.layers_button.observe(self._layers_btn_click, "value") - - super().__init__(children=[self.toolbar_header]) + self.accessory_widgets = accessory_widgets def reset(self): """Resets the toolbar so that no widget is selected.""" - for widget in self.all_widgets: + for widget in self.main_tools + self.extra_tools: widget.value = False - def toggle_layers(self, enabled): - self.layers_button.value = enabled - self.on_layers_toggled(enabled) - if enabled: - self.toolbar_button.value = False - - def _reset_others(self, current): - for other in self.all_widgets: - if other is not current: - other.value = False - def _toggle_callback(self, m, selected, item): del m, item # unused if not selected: return - if self.toggle_widget.icon == self._TOGGLE_TOOL_EXPAND_ICON: - self.grid.layout.grid_template_rows = ( - self.ICON_HEIGHT + " " - ) * self.num_rows_expanded - self.grid.children = self.all_widgets - self.toggle_widget.tooltip = self._TOGGLE_TOOL_COLLAPSE_TOOLTIP - self.toggle_widget.icon = self._TOGGLE_TOOL_COLLAPSE_ICON - elif self.toggle_widget.icon == self._TOGGLE_TOOL_COLLAPSE_ICON: - self.grid.layout.grid_template_rows = ( - self.ICON_HEIGHT + " " - ) * self.num_rows_collapsed - self.grid.children = self.all_widgets[: self.num_collapsed_tools] - self.toggle_widget.tooltip = self._TOGGLE_TOOL_EXPAND_TOOLTIP - self.toggle_widget.icon = self._TOGGLE_TOOL_EXPAND_ICON - - def _toolbar_btn_click(self, change): - if change["new"]: - self.layers_button.value = False - self.children = [self.toolbar_header, self.toolbar_footer] - else: - if not self.layers_button.value: - self.children = [self.toolbar_header] - - def _layers_btn_click(self, change): - # Allow callbacks to set accessory_widget to prevent flicker on click. - if self.on_layers_toggled: - self.on_layers_toggled(change["new"]) - if change["new"]: - self.toolbar_button.value = False - self.children = [self.toolbar_header, self.toolbar_footer] - else: - if not self.toolbar_button.value: - self.children = [self.toolbar_header] - - @property - def accessory_widget(self): - """A widget that temporarily replaces the tool grid.""" - return self._accessory_widget - - @accessory_widget.setter - def accessory_widget(self, value): - """Sets the widget that temporarily replaces the tool grid.""" - self._accessory_widget = value - if self._accessory_widget: - self.toolbar_footer.children = [self._accessory_widget] - else: - self.toolbar_footer.children = [self.grid] + if self.toggle_widget.icon == self._TOGGLE_EXPAND_ICON: + self.expanded = True + self.toggle_widget.tooltip_text = self._TOGGLE_COLLAPSE_TOOLTIP + self.toggle_widget.icon = self._TOGGLE_COLLAPSE_ICON + elif self.toggle_widget.icon == self._TOGGLE_COLLAPSE_ICON: + self.expanded = False + self.toggle_widget.tooltip_text = self._TOGGLE_EXPAND_TOOLTIP + self.toggle_widget.icon = self._TOGGLE_EXPAND_ICON def inspector_gui(m=None): @@ -608,7 +538,6 @@ def ee_plot_gui(m, position="topright", **kwargs): m (object): geemap.Map. position (str, optional): Position of the widget. Defaults to "topright". """ - close_btn = widgets.Button( icon="times", tooltip="Close the plot widget", @@ -722,7 +651,7 @@ def handle_interaction(**kwargs): dict_values = dict(zip(b_names, [dict_values_tmp[b] for b in b_names])) generate_chart(dict_values, latlon) except Exception as e: - if hasattr(m, "_plot_widget"): + if hasattr(m, "_plot_widget") and m._plot_widget is not None: m._plot_widget.clear_output() with m._plot_widget: print("No data for the clicked location.") @@ -4492,101 +4421,90 @@ def _cog_stac_inspector_callback(map, selected, item): main_tools = [ - Toolbar.Item( + ToolbarItem( icon="info", tooltip="Inspector", callback=_inspector_tool_callback, - reset=False, ), - Toolbar.Item( - icon="bar-chart", + ToolbarItem( + icon="bar_chart", tooltip="Plotting", callback=_plotting_tool_callback, - reset=False, ), - Toolbar.Item( - icon="globe", + ToolbarItem( + icon="history", tooltip="Create timelapse", callback=_timelapse_tool_callback, - reset=False, ), - Toolbar.Item( + ToolbarItem( icon="map", tooltip="Change basemap", callback=_basemap_tool_callback, - reset=False, ), - Toolbar.Item( - icon="retweet", + ToolbarItem( + icon="code", tooltip="Convert Earth Engine JavaScript to Python", callback=_convert_js_tool_callback, - reset=False, ), ] extra_tools = [ - Toolbar.Item( - icon="eraser", + ToolbarItem( + icon="ink_eraser", tooltip="Remove all drawn features", callback=lambda m, selected, _: m.remove_drawn_features() if selected else None, + reset=True, ), - Toolbar.Item( - icon="folder-open", + ToolbarItem( + icon="upload", tooltip="Open local vector/raster data", callback=_open_data_tool_callback, - reset=False, ), - Toolbar.Item( - icon="gears", + ToolbarItem( + icon="manufacturing", tooltip="WhiteboxTools for local geoprocessing", callback=_whitebox_tool_callback, - reset=False, ), - Toolbar.Item( - icon="google", + ToolbarItem( + icon="dns", tooltip="GEE Toolbox for cloud computing", callback=_gee_toolbox_tool_callback, - reset=False, ), - Toolbar.Item( - icon="fast-forward", + ToolbarItem( + icon="fast_forward", tooltip="Activate timeslider", callback=_time_slider_tool_callback, - reset=False, ), - Toolbar.Item( - icon="hand-o-up", + ToolbarItem( + icon="pan_tool_alt", tooltip="Collect training samples", callback=_collect_samples_tool_callback, - reset=False, ), - Toolbar.Item( - icon="line-chart", + ToolbarItem( + icon="show_chart", tooltip="Creating and plotting transects", callback=_plot_transect_tool_callback, - reset=False, ), - Toolbar.Item( - icon="random", + ToolbarItem( + icon="multiline_chart", tooltip="Sankey plots", callback=_sankee_tool_callback, - reset=False, ), - Toolbar.Item( - icon="adjust", + ToolbarItem( + icon="travel_explore", tooltip="Planet imagery", callback=_split_basemaps_tool_callback, ), - Toolbar.Item( - icon="info-circle", + ToolbarItem( + icon="target", tooltip="Get COG/STAC pixel value", callback=_cog_stac_inspector_callback, - reset=False, ), - Toolbar.Item( - icon="question", + ToolbarItem( + icon="question_mark", tooltip="Get help", callback=_open_help_page_callback, + reset=True, ), ] diff --git a/js/basemap_selector.ts b/js/basemap_selector.ts index 6f38f0a66e..e873749a28 100644 --- a/js/basemap_selector.ts +++ b/js/basemap_selector.ts @@ -52,7 +52,7 @@ export class BasemapSelector extends LitWidget< @property({ type: Array }) basemaps: string[] = []; @property({ type: String }) value: string = ""; - @query('select') selectElement!: HTMLSelectElement; + @query('select') selectElement!: HTMLSelectElement|null; render(): TemplateResult { return html` diff --git a/js/ipywidgets_styles.ts b/js/ipywidgets_styles.ts index fdb8162293..cb6c215263 100644 --- a/js/ipywidgets_styles.ts +++ b/js/ipywidgets_styles.ts @@ -57,6 +57,24 @@ export const legacyStyles = css` line-height: var(--jp-widgets-inline-height); } + .legacy-button.active { + background-color: var(--colab-primary-surface-color, --jp-layout-color3); + color: var(--jp-ui-font-color1); + box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)), + 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)), + 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)); + } + + .legacy-button.primary { + background-color: var(--jp-brand-color1); + color: var(--jp-ui-inverse-font-color1); + } + + .legacy-button.primary.active { + background-color: var(--jp-brand-color0); + color: var(--jp-ui-inverse-font-color0); + } + .legacy-select { -moz-appearance: none; -webkit-appearance: none; @@ -79,6 +97,5 @@ export const legacyStyles = css` padding-left: calc(var(--jp-widgets-input-padding)* 2); padding-right: 20px; vertical-align: top; -} } `; diff --git a/js/layer_manager_row.ts b/js/layer_manager_row.ts index bc2d4dbbca..33bcae4115 100644 --- a/js/layer_manager_row.ts +++ b/js/layer_manager_row.ts @@ -153,7 +153,7 @@ export class LayerManagerRow extends LitWidget< class="legacy-button row-button settings-button" @click="${this.onSettingsClicked}" > - + settings diff --git a/js/tab_panel.ts b/js/tab_panel.ts new file mode 100644 index 0000000000..e6458ccadf --- /dev/null +++ b/js/tab_panel.ts @@ -0,0 +1,183 @@ +import { html, css, nothing, LitElement, PropertyValues } from "lit"; +import { property, queryAll, queryAssignedElements } from "lit/decorators.js"; +import { legacyStyles } from "./ipywidgets_styles"; +import { classMap } from "lit/directives/class-map.js"; +import { materialStyles } from "./styles"; +import { styleMap } from "lit/directives/style-map.js"; + +function convertToId(name: string | undefined): string { + return (name || "").trim().replace(" ", "-").toLowerCase(); +} + +/** The various modes. */ +export enum TabMode { + ALWAYS_SHOW, + HIDE_ON_SECOND_CLICK, +} + +/** The tab configuration, as a string or Material Icon. */ +export interface Tab { + name: string | undefined, + icon: string | undefined, + width: number | undefined, +} + +/** + * Defines the element which accepts N children, with a zero-based + * index determining which child to show, e.g.: + * + *

Show when index is 0

+ *

Show when index is 1

+ *

Show when index is 2

+ *
+ */ +export class TabPanel extends LitElement { + static get componentName() { + return `tab-panel`; + } + + static styles = [ + legacyStyles, + materialStyles, + css` + ::slotted(*) { + display: none; + } + + ::slotted(.show-tab) { + display: block; + } + + .container { + padding: 0; + width: 100%; + } + + .tab-container { + align-items: center; + display: flex; + flex-direction: row; + justify-content: flex-end; + } + + .tab-container button { + border-radius: 5px; + height: 28px; + margin: 2px; + user-select: none; + } + + .tab-container button.icon { + font-size: 16px; + width: 28px; + } + + .tab-container button.name { + padding: 0 8px; + } + `, + ]; + + @property({ type: Array }) + tabs: Tab[] = []; + + @property({ type: Number }) + index = 0; + + @property({ type: Number }) + mode: TabMode = TabMode.HIDE_ON_SECOND_CLICK; + + /** + * The tab elements. + */ + @queryAll(".tab") tabElements!: HTMLDivElement[]; + + /** + * The tab content element to show at a given index. Note that child + * elements are set to display block or none based on the index, and + * top-level text elements are ignored. + */ + @queryAssignedElements() tabContentElements!: HTMLElement[]; + + render() { + return html` +
+
+ ${this.renderTabs()} +
+ +
+ `; + } + + override update(changedProperties: PropertyValues) { + super.update(changedProperties); + if (changedProperties.has("index") && changedProperties.get("index") != null) { + this.updateSlotChildren(); + } + } + + private updateSlotChildren() { + if (!this.tabContentElements) { + return; + } + // Show the element at the current index. + this.tabContentElements.forEach((element: HTMLElement, i: number) => { + element.classList.remove("show-tab"); + + // Also add accessibility attributes. + const id = convertToId(this.tabs[i].name); + element.setAttribute("id", `tabpanel-${id}-${i}`); + element.setAttribute("role", "tabpanel"); + element.setAttribute("aria-labelledby", `tab-${id}-${i}`); + }); + this.tabContentElements[this.index]?.classList.add("show-tab"); + } + + private renderTabs() { + return this.tabs.map((tab: Tab, i: number) => { + const id = convertToId(this.tabs[i].name); + return html``; + }); + } + + private onTabClick(index: number) { + switch (this.mode) { + case TabMode.HIDE_ON_SECOND_CLICK: + // Hide the tab panel if clicked twice. + this.index = this.index === index ? -1 : index; + break; + case TabMode.ALWAYS_SHOW: + default: + this.index = index; + } + this.dispatchEvent(new CustomEvent("tab-clicked", { + detail: index, + })); + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(TabPanel.componentName)) { + customElements.define(TabPanel.componentName, TabPanel); +} diff --git a/js/toolbar.ts b/js/toolbar.ts new file mode 100644 index 0000000000..7f488444be --- /dev/null +++ b/js/toolbar.ts @@ -0,0 +1,141 @@ +import type { RenderProps } from "@anywidget/types"; +import { html, css } from "lit"; +import { property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +import { legacyStyles } from "./ipywidgets_styles"; +import { LitWidget } from "./lit_widget"; +import { materialStyles } from "./styles"; +import { loadFonts, updateChildren } from "./utils"; + +import "./tab_panel"; + + +export interface ToolbarModel { + accessory_widgets: any; + main_tools: any; + extra_tools: any + expanded: boolean; + tab_index: number; +} + +export class Toolbar extends LitWidget< + ToolbarModel, + Toolbar +> { + static get componentName() { + return `toolbar-panel`; + } + + static styles = [ + legacyStyles, + materialStyles, + css` + .hide { + display: none; + } + + .expanded { + display: block; !important + } + + .tools-container { + padding: 4px; + } + + slot[name="extra-tools"] { + margin-top: 4px; + } + + ::slotted([slot="main-tools"]), + ::slotted([slot="extra-tools"]) { + align-items: center; + display: inline-grid; + grid-template-columns: auto auto auto; + grid-gap: 4px; + justify-items: center; + } + `, + ]; + + modelNameToViewName(): Map { + return new Map([ + ["accessory_widgets", null], + ["main_tools", null], + ["extra_tools", null], + ["expanded", "expanded"], + ["tab_index", "tab_index"], + ]); + } + + @property() + expanded: boolean = false; + + @property() + tab_index: number = 0; + + render() { + return html` + ) => { + this.tab_index = e.detail; + }}> +
+ +
+
+ + +
+
+ `; + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(Toolbar.componentName)) { + customElements.define(Toolbar.componentName, Toolbar); +} + +async function render({ model, el }: RenderProps) { + loadFonts(); + const manager = document.createElement(Toolbar.componentName); + manager.model = model; + el.appendChild(manager); + + const accessoryWidgetEl = document.createElement("div"); + accessoryWidgetEl.slot = "accessory-widget"; + manager.appendChild(accessoryWidgetEl); + + updateChildren(accessoryWidgetEl, model, "accessory_widgets"); + model.on("change:accessory_widgets", () => { + updateChildren(accessoryWidgetEl, model, "accessory_widgets"); + }); + + const mainToolsEl = document.createElement("div"); + mainToolsEl.slot = "main-tools"; + manager.appendChild(mainToolsEl); + + updateChildren(mainToolsEl, model, "main_tools"); + model.on("change:main_tools", () => { + updateChildren(mainToolsEl, model, "main_tools"); + }); + + const extraToolsEl = document.createElement("div"); + extraToolsEl.slot = "extra-tools"; + manager.appendChild(extraToolsEl); + + updateChildren(extraToolsEl, model, "extra_tools"); + model.on("change:extra_tools", () => { + updateChildren(extraToolsEl, model, "extra_tools"); + }); +} + +export default { render }; diff --git a/js/toolbar_item.ts b/js/toolbar_item.ts new file mode 100644 index 0000000000..24e4fe3939 --- /dev/null +++ b/js/toolbar_item.ts @@ -0,0 +1,85 @@ +import type { RenderProps } from "@anywidget/types"; +import { html, css } from "lit"; +import { property } from "lit/decorators.js"; +import { classMap } from 'lit/directives/class-map.js'; + +import { legacyStyles } from './ipywidgets_styles'; +import { LitWidget } from "./lit_widget"; +import { materialStyles } from "./styles"; +import { loadFonts } from "./utils"; + +export interface ToolbarItemModel { + active: boolean; + icon: string; + // Note: "tooltip" is already used by ipywidgets. + tooltip_text: string; +} + +export class ToolbarItem extends LitWidget { + static get componentName() { + return `tool-button`; + } + + static styles = [ + legacyStyles, + materialStyles, + css` + button { + font-size: 16px !important; + height: 32px; + padding: 0px 0px 0px 4px; + width: 32px; + user-select: none; + } + `, + ]; + + modelNameToViewName(): Map { + return new Map([ + ["active", "active"], + ["icon", "icon"], + ["tooltip_text", "tooltip_text"], + ]); + } + + @property({ type: Boolean }) + active: boolean = false; + + @property({ type: String }) + icon: string = ''; + + @property({ type: String }) + tooltip_text: string = ''; + + render() { + return html` + `; + } + + private onClick(_: Event) { + this.active = !this.active; + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(ToolbarItem.componentName)) { + customElements.define(ToolbarItem.componentName, ToolbarItem); +} + +async function render({ model, el }: RenderProps) { + loadFonts(); + const manager = document.createElement(ToolbarItem.componentName); + manager.model = model; + el.appendChild(manager); +} + +export default { render }; diff --git a/js/utils.ts b/js/utils.ts index 5431eaf7a2..c93ac022bc 100644 --- a/js/utils.ts +++ b/js/utils.ts @@ -24,9 +24,13 @@ export function loadFonts() { export async function updateChildren( container: HTMLElement, - model: AnyModel + model: AnyModel, + property = "children", ) { - const children = model.get("children"); + let children = model.get(property); + if (!Array.isArray(children)) { + children = [children] + } const child_models = await unpackModels(children, model.widget_manager); const child_views = await Promise.all( child_models.map((model) => model.widget_manager.create_view(model)) diff --git a/tests/test_core.py b/tests/test_core.py index cb1b44feb3..1d6261dae8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -150,44 +150,17 @@ def test_add_toolbar(self): self.assertEqual(len(self.core_map.controls), 1) toolbar_control = self.core_map.controls[0].widget - # Layer manager is selected and open by default. - layer_button = utils.query_widget( - toolbar_control, ipywidgets.ToggleButton, lambda c: c.tooltip == "Layers" - ) - self.assertTrue(layer_button.value) - self.assertIsNotNone( - utils.query_widget(toolbar_control, map_widgets.LayerManager) - ) - toolbar_button = utils.query_widget( - toolbar_control, ipywidgets.ToggleButton, lambda c: c.tooltip == "Toolbar" - ) - toolbar_button.value = True # Open the grid of tools. - self.assertFalse(layer_button.value) - - tool_grid = utils.query_widget(toolbar_control, ipywidgets.GridBox).children - - self.assertEqual(len(tool_grid), 3) - self.assertEqual(tool_grid[0].tooltip, "Basemap selector") - self.assertEqual(tool_grid[1].tooltip, "Inspector") - self.assertEqual(tool_grid[2].tooltip, "Get help") - - # Closing the toolbar button shows both buttons in the header. - toolbar_button.value = False - self.assertIsNotNone( - utils.query_widget( - toolbar_control, - ipywidgets.ToggleButton, - lambda c: c.tooltip == "Toolbar", - ) - ) - self.assertIsNotNone( - utils.query_widget( - toolbar_control, - ipywidgets.ToggleButton, - lambda c: c.tooltip == "Layers", - ) - ) + # Layer manager is selected and open by default. + self.assertEqual(toolbar_control.tab_index, 0) + layer_button = toolbar_control.accessory_widgets[0] + self.assertIsInstance(layer_button, map_widgets.LayerManager) + + toolbar_control.tab_index = 1 + self.assertEqual(len(toolbar_control.main_tools), 3) + self.assertEqual(toolbar_control.main_tools[0].tooltip_text, "Basemap selector") + self.assertEqual(toolbar_control.main_tools[1].tooltip_text, "Inspector") + self.assertEqual(toolbar_control.main_tools[2].tooltip_text, "Get help") def test_add_draw_control(self): """Tests adding and getting the draw widget.""" diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index aed699e1d7..e8d093a014 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -10,39 +10,27 @@ import ipywidgets import geemap -from geemap.toolbar import Toolbar, _cleanup_toolbar_item +from geemap.toolbar import Toolbar, ToolbarItem, _cleanup_toolbar_item from tests import fake_map, utils class TestToolbar(unittest.TestCase): """Tests for the Toolbar class in the `toolbar` module.""" - def _query_layers_button(self, toolbar): - return utils.query_widget( - toolbar, ipywidgets.ToggleButton, lambda c: c.tooltip == "Layers" - ) - - def _query_open_button(self, toolbar): - return utils.query_widget( - toolbar, ipywidgets.ToggleButton, lambda c: c.tooltip == "Toolbar" - ) - - def _query_tool_grid(self, toolbar): - return utils.query_widget(toolbar, ipywidgets.GridBox, lambda c: True) - def setUp(self) -> None: self.callback_calls = 0 self.last_called_with_selected = None self.last_called_item = None - self.item = Toolbar.Item( + self.item = ToolbarItem( icon="info", tooltip="dummy item", callback=self.dummy_callback ) - self.no_reset_item = Toolbar.Item( + self.reset_item = ToolbarItem( icon="question", tooltip="no reset item", callback=self.dummy_callback, - reset=False, + reset=True, ) + self.accessory_widgets = [ipywidgets.Text()] return super().setUp() def tearDown(self) -> None: @@ -57,109 +45,62 @@ def dummy_callback(self, m, selected, item): def test_no_tools_throws(self): map = geemap.Map(ee_initialize=False) - self.assertRaises(ValueError, Toolbar, map, [], []) + self.assertRaises(ValueError, Toolbar, map, [], [], self.accessory_widgets) def test_only_main_tools_exist_if_no_extra_tools(self): map = geemap.Map(ee_initialize=False) - toolbar = Toolbar(map, [self.item], []) - self.assertIsNone(toolbar.toggle_widget) - self.assertEqual(len(toolbar.all_widgets), 1) - self.assertEqual(toolbar.all_widgets[0].icon, "info") - self.assertEqual(toolbar.all_widgets[0].tooltip, "dummy item") - self.assertFalse(toolbar.all_widgets[0].value) - self.assertEqual(toolbar.num_rows_collapsed, 1) - self.assertEqual(toolbar.num_rows_expanded, 1) + toolbar = Toolbar(map, [self.item], [], self.accessory_widgets) + self.assertNotIn(toolbar.toggle_widget, toolbar.main_tools) def test_all_tools_and_toggle_exist_if_extra_tools(self): map = geemap.Map(ee_initialize=False) - toolbar = Toolbar(map, [self.item], [self.no_reset_item]) + toolbar = Toolbar(map, [self.item], [self.reset_item], self.accessory_widgets) self.assertIsNotNone(toolbar.toggle_widget) - self.assertEqual(len(toolbar.all_widgets), 3) - self.assertEqual(toolbar.all_widgets[2].icon, "question") - self.assertEqual(toolbar.all_widgets[2].tooltip, "no reset item") - self.assertFalse(toolbar.all_widgets[2].value) - self.assertEqual(toolbar.num_rows_collapsed, 1) - self.assertEqual(toolbar.num_rows_expanded, 1) - - def test_has_correct_number_of_rows(self): - map = geemap.Map(ee_initialize=False) - toolbar = Toolbar(map, [self.item, self.item], [self.item, self.item]) - self.assertEqual(toolbar.num_rows_collapsed, 1) - self.assertEqual(toolbar.num_rows_expanded, 2) def test_toggle_expands_and_collapses(self): map = geemap.Map(ee_initialize=False) - toolbar = Toolbar(map, [self.item], [self.no_reset_item]) - self.assertEqual(len(toolbar.grid.children), 2) + toolbar = Toolbar(map, [self.item], [self.reset_item], self.accessory_widgets) self.assertIsNotNone(toolbar.toggle_widget) - toggle = toolbar.all_widgets[1] - self.assertEqual(toggle.icon, "plus") - self.assertEqual(toggle.tooltip, "Expand toolbar") + toggle = toolbar.toggle_widget + self.assertEqual(toggle.icon, "add") + self.assertEqual(toggle.tooltip_text, "Expand toolbar") + self.assertFalse(toolbar.expanded) # Expand - toggle.value = True - self.assertEqual(len(toolbar.grid.children), 3) - self.assertEqual(toggle.icon, "minus") - self.assertEqual(toggle.tooltip, "Collapse toolbar") + toggle.active = True + self.assertTrue(toolbar.expanded) + self.assertEqual(toggle.icon, "remove") + self.assertEqual(toggle.tooltip_text, "Collapse toolbar") # After expanding, button is unselected. - self.assertFalse(toggle.value) + self.assertFalse(toggle.active) # Collapse - toggle.value = True - self.assertEqual(len(toolbar.grid.children), 2) - self.assertEqual(toggle.icon, "plus") - self.assertEqual(toggle.tooltip, "Expand toolbar") + toggle.active = True + self.assertFalse(toolbar.expanded) + self.assertEqual(toggle.icon, "add") + self.assertEqual(toggle.tooltip_text, "Expand toolbar") # After collapsing, button is unselected. - self.assertFalse(toggle.value) + self.assertFalse(toggle.active) def test_triggers_callbacks(self): map = geemap.Map(ee_initialize=False) - toolbar = Toolbar(map, [self.item, self.no_reset_item]) + toolbar = Toolbar(map, [self.item, self.reset_item], [], self.accessory_widgets) self.assertIsNone(self.last_called_with_selected) self.assertIsNone(self.last_called_item) - # Select first tool, which resets. - toolbar.all_widgets[0].value = True - self.assertFalse(self.last_called_with_selected) # was reset by callback - self.assertEqual(self.callback_calls, 2) - self.assertFalse(toolbar.all_widgets[0].value) + # Select first tool, which does not reset. + toolbar.main_tools[0].active = True + self.assertTrue(self.last_called_with_selected) + self.assertEqual(self.callback_calls, 1) + self.assertTrue(toolbar.main_tools[0].active) self.assertEqual(self.item, self.last_called_item) - # Select second tool, which does not reset. - toolbar.all_widgets[1].value = True - self.assertTrue(self.last_called_with_selected) + # Select second tool, which resets. + toolbar.main_tools[1].active = True + self.assertFalse(self.last_called_with_selected) # was reset by callback self.assertEqual(self.callback_calls, 3) - self.assertTrue(toolbar.all_widgets[1].value) - self.assertEqual(self.no_reset_item, self.last_called_item) - - def test_layers_toggle_callback(self): - """Verifies the on_layers_toggled callback is triggered.""" - map_fake = fake_map.FakeMap() - toolbar = Toolbar(map_fake, [self.item, self.no_reset_item]) - self._query_open_button(toolbar).value = True - - self.assertIsNotNone(layers_button := self._query_layers_button(toolbar)) - on_toggled_mock = Mock() - toolbar.on_layers_toggled = on_toggled_mock - layers_button.value = True - - on_toggled_mock.assert_called_once() - - def test_accessory_widget(self): - """Verifies the accessory widget replaces the tool grid.""" - map_fake = fake_map.FakeMap() - toolbar = Toolbar(map_fake, [self.item, self.no_reset_item]) - self._query_open_button(toolbar).value = True - self.assertIsNotNone(self._query_tool_grid(toolbar)) - - toolbar.accessory_widget = ipywidgets.ToggleButton(tooltip="test-button") - - self.assertIsNone(self._query_tool_grid(toolbar)) - self.assertIsNotNone( - utils.query_widget( - toolbar, ipywidgets.ToggleButton, lambda c: c.tooltip == "test-button" - ) - ) + self.assertFalse(toolbar.main_tools[1].active) + self.assertEqual(self.reset_item, self.last_called_item) @dataclass class TestWidget: @@ -177,24 +118,24 @@ def callback(m, selected, item): widget.selected_count += 1 return widget - item = Toolbar.Item( + item = ToolbarItem( icon="info", tooltip="dummy item", callback=callback, reset=False ) map_fake = fake_map.FakeMap() - toolbar = Toolbar(map_fake, [item]) - toolbar.all_widgets[0].value = True + toolbar = Toolbar(map_fake, [item], [], self.accessory_widgets) + toolbar.main_tools[0].active = True self.assertEqual(1, widget.selected_count) self.assertEqual(0, widget.cleanup_count) - toolbar.all_widgets[0].value = False + toolbar.main_tools[0].active = False self.assertEqual(1, widget.selected_count) self.assertEqual(1, widget.cleanup_count) - toolbar.all_widgets[0].value = True + toolbar.main_tools[0].active = True self.assertEqual(2, widget.selected_count) self.assertEqual(1, widget.cleanup_count) widget.cleanup() self.assertEqual(2, widget.selected_count) self.assertEqual(3, widget.cleanup_count) - self.assertFalse(toolbar.all_widgets[0].value) + self.assertFalse(toolbar.main_tools[0].active)