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