Edouard@2922: // widget_dropdown.ysl2 Edouard@2922: edouard@3241: widget_desc("DropDown") { edouard@3241: edouard@3241: longdesc edouard@3241: || edouard@3241: DropDown widget let user select an entry in a list of texts, given as edouard@3241: arguments. Single variable path is index of selection. edouard@3241: edouard@3352: It needs "text" (svg:text or svg:use referring to svg:text), edouard@3352: "box" (svg:rect), "button" (svg:*), and "highlight" (svg:rect) edouard@3352: labeled elements. edouard@3241: edouard@3241: When user clicks on "button", "text" is duplicated to display enties in the edouard@3241: limit of available space in page, and "box" is extended to contain all edouard@3241: texts. "highlight" is moved over pre-selected entry. edouard@3241: edouard@3352: When only one argument is given and argment contains "#langs" then list of edouard@3352: texts is automatically set to the human-readable list of supported edouard@3352: languages by this HMI. edouard@3352: edouard@3352: If "text" labeled element is of type svg:use and refers to a svg:text edouard@3352: element part of a TextList widget, no argument is expected. In that case edouard@3352: list of texts is set to TextList content. edouard@3241: || edouard@3241: edouard@3241: shortdesc > Let user select text entry in a drop-down menu edouard@3241: edouard@3241: arg name="entries" count="many" accepts="string" > drop-down menu entries edouard@3241: edouard@3241: path name="selection" accepts="HMI_INT" > selection index edouard@3241: } edouard@3241: edouard@3241: // TODO: support i18n of menu entries using svg:text elements with labels starting with "_" edouard@3241: edouard@3232: widget_class("DropDown") { Edouard@3090: || Edouard@3090: dispatch(value) { Edouard@3090: if(!this.opened) this.set_selection(value); Edouard@3090: } Edouard@3090: init() { edouard@3352: this.init_specific(); Edouard@3090: this.button_elt.onclick = this.on_button_click.bind(this); Edouard@3090: // Save original size of rectangle Edouard@3090: this.box_bbox = this.box_elt.getBBox() Edouard@3092: this.highlight_bbox = this.highlight_elt.getBBox() Edouard@3092: this.highlight_elt.style.visibility = "hidden"; Edouard@3090: Edouard@3090: // Compute margins Edouard@3091: this.text_bbox = this.text_elt.getBBox(); Edouard@3091: let lmargin = this.text_bbox.x - this.box_bbox.x; Edouard@3091: let tmargin = this.text_bbox.y - this.box_bbox.y; Edouard@3090: this.margins = [lmargin, tmargin].map(x => Math.max(x,0)); Edouard@3090: Edouard@3090: // Index of first visible element in the menu, when opened Edouard@3090: this.menu_offset = 0; Edouard@3090: Edouard@3090: // How mutch to lift the menu vertically so that it does not cross bottom border Edouard@3090: this.lift = 0; Edouard@3090: Edouard@3090: // Event handlers cannot be object method ('this' is unknown) Edouard@3090: // as a workaround, handler given to addEventListener is bound in advance. Edouard@3090: this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this); Edouard@3090: this.bound_on_selection_click = this.on_selection_click.bind(this); Edouard@3090: this.bound_on_backward_click = this.on_backward_click.bind(this); Edouard@3090: this.bound_on_forward_click = this.on_forward_click.bind(this); Edouard@3090: this.opened = false; Edouard@3091: this.clickables = []; Edouard@3090: } Edouard@3090: on_button_click() { Edouard@3090: this.open(); Edouard@3090: } Edouard@3090: // Called when a menu entry is clicked Edouard@3090: on_selection_click(selection) { Edouard@3090: this.close(); Edouard@3090: this.apply_hmi_value(0, selection); Edouard@3090: } Edouard@3090: on_backward_click(){ Edouard@3090: this.scroll(false); Edouard@3090: } Edouard@3090: on_forward_click(){ Edouard@3090: this.scroll(true); Edouard@3090: } Edouard@3090: set_selection(value) { Edouard@3090: let display_str; Edouard@3090: if(value >= 0 && value < this.content.length){ Edouard@3090: // if valid selection resolve content edouard@3352: display_str = gettext(this.content[value]); Edouard@3090: this.last_selection = value; Edouard@3090: } else { Edouard@3090: // otherwise show problem Edouard@3090: display_str = "?"+String(value)+"?"; Edouard@3090: } Edouard@3090: // It is assumed that first span always stays, Edouard@3090: // and contains selection when menu is closed Edouard@3090: this.text_elt.firstElementChild.textContent = display_str; Edouard@3090: } Edouard@3090: grow_text(up_to) { Edouard@3090: let count = 1; Edouard@3091: let txt = this.text_elt; Edouard@3090: let first = txt.firstElementChild; Edouard@3090: // Real world (pixels) boundaries of current page Edouard@3091: let bounds = svg_root.getBoundingClientRect(); Edouard@3090: this.lift = 0; Edouard@3090: while(count < up_to) { Edouard@3090: let next = first.cloneNode(); Edouard@3090: // relative line by line text flow instead of absolute y coordinate Edouard@3090: next.removeAttribute("y"); Edouard@3090: next.setAttribute("dy", "1.1em"); Edouard@3090: // default content to allow computing text element bbox Edouard@3090: next.textContent = "..."; Edouard@3090: // append new span to text element Edouard@3090: txt.appendChild(next); Edouard@3090: // now check if text extended by one row fits to page Edouard@3090: // FIXME : exclude margins to be more accurate on box size Edouard@3090: let rect = txt.getBoundingClientRect(); Edouard@3090: if(rect.bottom > bounds.bottom){ Edouard@3090: // in case of overflow at the bottom, lift up one row Edouard@3090: let backup = first.getAttribute("dy"); Edouard@3092: // apply lift as a dy added too first span (y attrib stays) Edouard@3090: first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em"); Edouard@3090: rect = txt.getBoundingClientRect(); Edouard@3090: if(rect.top > bounds.top){ Edouard@3090: this.lift += 1; Edouard@3090: } else { Edouard@3090: // if it goes over the top, then backtrack Edouard@3090: // restore dy attribute on first span Edouard@3090: if(backup) Edouard@3090: first.setAttribute("dy", backup); Edouard@3090: else Edouard@3090: first.removeAttribute("dy"); Edouard@3090: // remove unwanted child Edouard@3090: txt.removeChild(next); Edouard@3090: return count; Edouard@3090: } Edouard@3090: } Edouard@3090: count++; Edouard@3090: } Edouard@3090: return count; Edouard@3090: } Edouard@3090: close_on_click_elsewhere(e) { Edouard@3090: // inhibit events not targetting spans (menu items) Edouard@3091: if([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){ Edouard@3090: e.stopPropagation(); Edouard@3090: // close menu in case click is outside box Edouard@3090: if(e.target !== this.box_elt) Edouard@3090: this.close(); Edouard@3090: } Edouard@3090: } Edouard@3090: close(){ Edouard@3090: // Stop hogging all click events edouard@3232: svg_root.removeEventListener("pointerdown", this.numb_event, true); edouard@3232: svg_root.removeEventListener("pointerup", this.numb_event, true); Edouard@3090: svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true); Edouard@3090: // Restore position and sixe of widget elements Edouard@3090: this.reset_text(); Edouard@3091: this.reset_clickables(); Edouard@3090: this.reset_box(); Edouard@3092: this.reset_highlight(); Edouard@3090: // Put the button back in place Edouard@3090: this.element.appendChild(this.button_elt); Edouard@3090: // Mark as closed (to allow dispatch) Edouard@3090: this.opened = false; Edouard@3090: // Dispatch last cached value Edouard@3090: this.apply_cache(); Edouard@3090: } Edouard@3091: // Make item (text span) clickable by overlaying a rectangle on top of it Edouard@3091: make_clickable(span, func) { Edouard@3091: let txt = this.text_elt; Edouard@3091: let original_text_y = this.text_bbox.y; Edouard@3091: let highlight = this.highlight_elt; Edouard@3092: let original_h_y = this.highlight_bbox.y; Edouard@3091: let clickable = highlight.cloneNode(); Edouard@3091: let yoffset = span.getBBox().y - original_text_y; Edouard@3092: clickable.y.baseVal.value = original_h_y + yoffset; Edouard@3091: clickable.style.pointerEvents = "bounding-box"; Edouard@3092: //clickable.style.visibility = "hidden"; Edouard@3091: //clickable.onclick = () => alert("love JS"); Edouard@3091: clickable.onclick = func; Edouard@3091: this.element.appendChild(clickable); Edouard@3091: this.clickables.push(clickable) Edouard@3091: } Edouard@3091: reset_clickables() { Edouard@3091: while(this.clickables.length){ Edouard@3091: this.element.removeChild(this.clickables.pop()); Edouard@3091: } Edouard@3091: } Edouard@3090: // Set text content when content is smaller than menu (no scrolling) Edouard@3090: set_complete_text(){ Edouard@3091: let spans = this.text_elt.children; Edouard@3090: let c = 0; Edouard@3090: for(let item of this.content){ Edouard@3090: let span=spans[c]; edouard@3352: span.textContent = gettext(item); Edouard@3090: let sel = c; Edouard@3091: this.make_clickable(span, (evt) => this.bound_on_selection_click(sel)); Edouard@3090: c++; Edouard@3090: } Edouard@3090: } Edouard@3090: // Move partial view : Edouard@3090: // false : upward, lower value Edouard@3090: // true : downward, higher value Edouard@3090: scroll(forward){ Edouard@3090: let contentlength = this.content.length; Edouard@3091: let spans = this.text_elt.children; Edouard@3090: let spanslength = spans.length; Edouard@3092: // reduce accounted menu size according to prsence of scroll buttons Edouard@3092: // since we scroll there is necessarly one button Edouard@3092: spanslength--; Edouard@3090: if(forward){ Edouard@3092: // reduce accounted menu size because of back button Edouard@3092: // in current view Edouard@3092: if(this.menu_offset > 0) spanslength--; Edouard@3090: this.menu_offset = Math.min( Edouard@3091: contentlength - spans.length + 1, Edouard@3090: this.menu_offset + spanslength); Edouard@3090: }else{ Edouard@3092: // reduce accounted menu size because of back button Edouard@3092: // in view once scrolled Edouard@3092: if(this.menu_offset - spanslength > 0) spanslength--; Edouard@3090: this.menu_offset = Math.max( Edouard@3091: 0, Edouard@3090: this.menu_offset - spanslength); Edouard@3090: } Edouard@3092: if(this.menu_offset == 1) Edouard@3092: this.menu_offset = 0; Edouard@3092: Edouard@3092: this.reset_highlight(); Edouard@3092: Edouard@3091: this.reset_clickables(); Edouard@3090: this.set_partial_text(); Edouard@3092: Edouard@3092: this.highlight_selection(); Edouard@3090: } Edouard@3090: // Setup partial view text content Edouard@3090: // with jumps at first and last entry when appropriate Edouard@3090: set_partial_text(){ Edouard@3091: let spans = this.text_elt.children; Edouard@3090: let contentlength = this.content.length; Edouard@3090: let spanslength = spans.length; Edouard@3090: let i = this.menu_offset, c = 0; Edouard@3091: let m = this.box_bbox; Edouard@3090: while(c < spanslength){ Edouard@3090: let span=spans[c]; Edouard@3091: let onclickfunc; Edouard@3090: // backward jump only present if not exactly at start Edouard@3090: if(c == 0 && i != 0){ Edouard@3091: span.textContent = "▲"; Edouard@3091: onclickfunc = this.bound_on_backward_click; Edouard@3091: let o = span.getBBox(); Edouard@3091: span.setAttribute("dx", (m.width - o.width)/2); Edouard@3090: // presence of forward jump when not right at the end Edouard@3090: }else if(c == spanslength-1 && i < contentlength - 1){ Edouard@3091: span.textContent = "▼"; Edouard@3091: onclickfunc = this.bound_on_forward_click; Edouard@3091: let o = span.getBBox(); Edouard@3091: span.setAttribute("dx", (m.width - o.width)/2); Edouard@3090: // otherwise normal content Edouard@3090: }else{ edouard@3352: span.textContent = gettext(this.content[i]); Edouard@3090: let sel = i; Edouard@3091: onclickfunc = (evt) => this.bound_on_selection_click(sel); Edouard@3091: span.removeAttribute("dx"); Edouard@3090: i++; Edouard@3090: } Edouard@3091: this.make_clickable(span, onclickfunc); Edouard@3090: c++; Edouard@3090: } Edouard@3090: } edouard@3232: numb_event(e) { edouard@3232: e.stopPropagation(); edouard@3232: } Edouard@3090: open(){ Edouard@3090: let length = this.content.length; Edouard@3090: // systematically reset text, to strip eventual whitespace spans Edouard@3090: this.reset_text(); Edouard@3090: // grow as much as needed or possible Edouard@3090: let slots = this.grow_text(length); Edouard@3090: // Depending on final size Edouard@3090: if(slots == length) { Edouard@3090: // show all at once Edouard@3090: this.set_complete_text(); Edouard@3090: } else { Edouard@3090: // eventualy align menu to current selection, compensating for lift Edouard@3090: let offset = this.last_selection - this.lift; Edouard@3090: if(offset > 0) Edouard@3090: this.menu_offset = Math.min(offset + 1, length - slots + 1); Edouard@3090: else Edouard@3090: this.menu_offset = 0; Edouard@3090: // show surrounding values Edouard@3090: this.set_partial_text(); Edouard@3090: } Edouard@3090: // Now that text size is known, we can set the box around it Edouard@3090: this.adjust_box_to_text(); Edouard@3090: // Take button out until menu closed Edouard@3090: this.element.removeChild(this.button_elt); Edouard@3090: // Rise widget to top by moving it to last position among siblings Edouard@3090: this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element)); Edouard@3090: // disable interaction with background edouard@3232: svg_root.addEventListener("pointerdown", this.numb_event, true); edouard@3232: svg_root.addEventListener("pointerup", this.numb_event, true); Edouard@3090: svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true); Edouard@3092: this.highlight_selection(); Edouard@3092: Edouard@3090: // mark as open Edouard@3090: this.opened = true; Edouard@3090: } Edouard@3090: // Put text element in normalized state Edouard@3090: reset_text(){ Edouard@3091: let txt = this.text_elt; Edouard@3090: let first = txt.firstElementChild; Edouard@3090: // remove attribute eventually added to first text line while opening Edouard@3091: first.onclick = null; Edouard@3090: first.removeAttribute("dy"); Edouard@3091: first.removeAttribute("dx"); Edouard@3090: // keep only the first line of text Edouard@3090: for(let span of Array.from(txt.children).slice(1)){ Edouard@3090: txt.removeChild(span) Edouard@3090: } Edouard@3090: } Edouard@3090: // Put rectangle element in saved original state Edouard@3090: reset_box(){ Edouard@3090: let m = this.box_bbox; Edouard@3090: let b = this.box_elt; Edouard@3090: b.x.baseVal.value = m.x; Edouard@3090: b.y.baseVal.value = m.y; Edouard@3090: b.width.baseVal.value = m.width; Edouard@3090: b.height.baseVal.value = m.height; Edouard@3090: } Edouard@3092: highlight_selection(){ edouard@3131: if(this.last_selection == undefined) return; Edouard@3092: let highlighted_row = this.last_selection - this.menu_offset; Edouard@3092: if(highlighted_row < 0) return; Edouard@3092: let spans = this.text_elt.children; Edouard@3092: let spanslength = spans.length; Edouard@3092: let contentlength = this.content.length; Edouard@3092: if(this.menu_offset != 0) { Edouard@3092: spanslength--; Edouard@3092: highlighted_row++; Edouard@3092: } Edouard@3092: if(this.menu_offset + spanslength < contentlength - 1) spanslength--; Edouard@3092: if(highlighted_row > spanslength) return; Edouard@3092: let original_text_y = this.text_bbox.y; Edouard@3092: let highlight = this.highlight_elt; Edouard@3092: let span = spans[highlighted_row]; Edouard@3092: let yoffset = span.getBBox().y - original_text_y; Edouard@3092: highlight.y.baseVal.value = this.highlight_bbox.y + yoffset; Edouard@3092: highlight.style.visibility = "visible"; Edouard@3092: } Edouard@3092: reset_highlight(){ Edouard@3092: let highlight = this.highlight_elt; Edouard@3092: highlight.y.baseVal.value = this.highlight_bbox.y; Edouard@3092: highlight.style.visibility = "hidden"; Edouard@3092: } Edouard@3090: // Use margin and text size to compute box size Edouard@3090: adjust_box_to_text(){ Edouard@3090: let [lmargin, tmargin] = this.margins; Edouard@3090: let m = this.text_elt.getBBox(); Edouard@3090: let b = this.box_elt; Edouard@3091: // b.x.baseVal.value = m.x - lmargin; Edouard@3090: b.y.baseVal.value = m.y - tmargin; Edouard@3091: // b.width.baseVal.value = 2 * lmargin + m.width; Edouard@3090: b.height.baseVal.value = 2 * tmargin + m.height; Edouard@3090: } Edouard@3090: || Edouard@3090: } Edouard@3091: edouard@3232: widget_defs("DropDown") { edouard@3352: labels("box button highlight"); edouard@3107: // It is assumed that list content conforms to Array interface. edouard@3352: const "text_elt","$hmi_element//*[@inkscape:label='text'][1]"; edouard@3352: | init_specific: function() { edouard@3133: choose{ edouard@3133: // special case when used for language selection edouard@3133: when "count(arg) = 1 and arg[1]/@value = '#langs'" { edouard@3352: | this.text_elt = id("«$text_elt/@id»"); edouard@3352: | this.content = langs; edouard@3352: } edouard@3352: when "count(arg) = 0"{ edouard@3352: if "not($text_elt[self::svg:use])" edouard@3352: error > No argrument for HMI:DropDown widget id="«$hmi_element/@id»" and "text" labeled element is not a svg:use element edouard@3352: const "real_text_elt","$result_widgets[@id = $hmi_element/@id]//*[@original=$text_elt/@id]/svg:text"; edouard@3352: | this.text_elt = id("«$real_text_elt/@id»"); edouard@3352: const "from_list_id", "substring-after($text_elt/@xlink:href,'#')"; edouard@3352: const "from_list", "$hmi_textlists[(@id | */@id) = $from_list_id]"; edouard@3352: if "count($from_list) = 0" edouard@3352: error > HMI:DropDown widget id="«$hmi_element/@id»" "text" labeled element does not point to a svg:text owned by a HMI:List widget edouard@3352: | this.content = hmi_widgets["«$from_list/@id»"].texts; edouard@3133: } edouard@3133: otherwise { edouard@3352: | this.text_elt = id("«$text_elt/@id»"); edouard@3352: | this.content = [ edouard@3133: foreach "arg" | "«@value»", edouard@3352: | ]; edouard@3133: } edouard@3133: } edouard@3352: | } Edouard@2922: } edouard@3352: edouard@3352: emit "declarations:DropDown" edouard@3352: || edouard@3352: function gettext(o) { edouard@3352: if(typeof(o) == "string"){ edouard@3352: return o; edouard@3352: } edouard@3352: return svg_text_to_multiline(o); edouard@3352: }; edouard@3352: ||