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: ||