svghmi/widget_dropdown.ysl2
author Edouard Tisserant <edouard.tisserant@gmail.com>
Wed, 19 Jan 2022 08:51:45 +0100
changeset 3412 04c4835ca376
parent 3352 3a138ccdfafa
child 3487 efa45e7cb04b
permissions -rw-r--r--
SVGHMI: Fixed support for frequency parsing in widget label
// widget_dropdown.ysl2

widget_desc("DropDown") {

    longdesc
    ||
    DropDown widget let user select an entry in a list of texts, given as
    arguments. Single variable path is index of selection.

    It needs "text" (svg:text or svg:use referring to svg:text),
    "box" (svg:rect), "button" (svg:*), and "highlight" (svg:rect)
    labeled elements.

    When user clicks on "button", "text" is duplicated to display enties in the
    limit of available space in page, and "box" is extended to contain all
    texts. "highlight" is moved over pre-selected entry.

    When only one argument is given and argment contains "#langs" then list of
    texts is automatically set to the human-readable list of supported
    languages by this HMI. 

    If "text" labeled element is of type svg:use and refers to a svg:text 
    element part of a TextList widget, no argument is expected. In that case
    list of texts is set to TextList content.
    ||

    shortdesc > Let user select text entry in a drop-down menu

    arg name="entries" count="many" accepts="string" > drop-down menu entries

    path name="selection" accepts="HMI_INT" > selection index
}

// TODO: support i18n of menu entries using svg:text elements with labels starting with "_"

widget_class("DropDown") {
||
        dispatch(value) {
            if(!this.opened) this.set_selection(value);
        }
        init() {
            this.init_specific();
            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";

            // Compute margins
            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
            this.menu_offset = 0;

            // How mutch to lift the menu vertically so that it does not cross bottom border
            this.lift = 0;

            // 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);
            this.opened = false;
            this.clickables = [];
        }
        on_button_click() {
            this.open();
        }
        // Called when a menu entry is clicked
        on_selection_click(selection) {
            this.close();
            this.apply_hmi_value(0, selection);
        }
        on_backward_click(){
            this.scroll(false);
        }
        on_forward_click(){
            this.scroll(true);
        }
        set_selection(value) {
            let display_str;
            if(value >= 0 && value < this.content.length){
                // if valid selection resolve content
                display_str = gettext(this.content[value]);
                this.last_selection = value;
            } else {
                // 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;
        }
        grow_text(up_to) {
            let count = 1;
            let txt = this.text_elt;
            let first = txt.firstElementChild;
            // Real world (pixels) boundaries of current page
            let bounds = svg_root.getBoundingClientRect();
            this.lift = 0;
            while(count < up_to) {
                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
                txt.appendChild(next);
                // 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){
                        this.lift += 1;
                    } else {
                        // if it goes over the top, then backtrack
                        // restore dy attribute on first span
                        if(backup)
                            first.setAttribute("dy", backup);
                        else
                            first.removeAttribute("dy");
                        // remove unwanted child
                        txt.removeChild(next);
                        return count;
                    }
                }
                count++;
            }
            return count;
        }
        close_on_click_elsewhere(e) {
            // inhibit events not targetting spans (menu items)
            if([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){
                e.stopPropagation();
                // close menu in case click is outside box
                if(e.target !== this.box_elt)
                    this.close();
            }
        }
        close(){
            // 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
            this.reset_text();
            this.reset_clickables();
            this.reset_box();
            this.reset_highlight();
            // Put the button back in place
            this.element.appendChild(this.button_elt);
            // Mark as closed (to allow dispatch)
            this.opened = false;
            // Dispatch last cached value
            this.apply_cache();
        }
        // Make item (text span) clickable by overlaying a rectangle on top of it
        make_clickable(span, func) {
            let txt = this.text_elt;
            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)
        }
        reset_clickables() {
            while(this.clickables.length){
                this.element.removeChild(this.clickables.pop());
            }
        }
        // Set text content when content is smaller than menu (no scrolling)
        set_complete_text(){
            let spans = this.text_elt.children;
            let c = 0;
            for(let item of this.content){
                let span=spans[c];
                span.textContent = gettext(item);
                let sel = c;
                this.make_clickable(span, (evt) => this.bound_on_selection_click(sel));
                c++;
            }
        }
        // Move partial view :
        // false : upward, lower value
        // true  : downward, higher value
        scroll(forward){
            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
            spanslength--;
            if(forward){
                // reduce accounted menu size because of back button
                // in current view
                if(this.menu_offset > 0) spanslength--;
                this.menu_offset = Math.min(
                    contentlength - spans.length + 1,
                    this.menu_offset + spanslength);
            }else{
                // reduce accounted menu size because of back button
                // in view once scrolled
                if(this.menu_offset - spanslength > 0) spanslength--;
                this.menu_offset = Math.max(
                    0,
                    this.menu_offset - spanslength);
            }
            if(this.menu_offset == 1)
                this.menu_offset = 0;

            this.reset_highlight();

            this.reset_clickables();
            this.set_partial_text();

            this.highlight_selection();
        }
        // Setup partial view text content
        // with jumps at first and last entry when appropriate
        set_partial_text(){
            let spans = this.text_elt.children;
            let contentlength = this.content.length;
            let spanslength = spans.length;
            let i = this.menu_offset, c = 0;
            let m = this.box_bbox;
            while(c < spanslength){
                let span=spans[c];
                let onclickfunc;
                // backward jump only present if not exactly at start
                if(c == 0 && i != 0){
                    span.textContent = "▲";
                    onclickfunc = this.bound_on_backward_click;
                    let o = span.getBBox();
                    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){
                    span.textContent = "▼";
                    onclickfunc = this.bound_on_forward_click;
                    let o = span.getBBox();
                    span.setAttribute("dx", (m.width - o.width)/2);
                // otherwise normal content
                }else{
                    span.textContent = gettext(this.content[i]);
                    let sel = i;
                    onclickfunc = (evt) => this.bound_on_selection_click(sel);
                    span.removeAttribute("dx");
                    i++;
                }
                this.make_clickable(span, onclickfunc);
                c++;
            }
        }
        numb_event(e) {
             e.stopPropagation();
        }
        open(){
            let length = this.content.length;
            // systematically reset text, to strip eventual whitespace spans
            this.reset_text();
            // grow as much as needed or possible
            let slots = this.grow_text(length);
            // Depending on final size
            if(slots == length) {
                // show all at once
                this.set_complete_text();
            } else {
                // eventualy align menu to current selection, compensating for lift
                let offset = this.last_selection - this.lift;
                if(offset > 0)
                    this.menu_offset = Math.min(offset + 1, length - slots + 1);
                else
                    this.menu_offset = 0;
                // show surrounding values
                this.set_partial_text();
            }
            // 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();

            // mark as open
            this.opened = true;
        }
        // Put text element in normalized state
        reset_text(){
            let txt = this.text_elt;
            let first = txt.firstElementChild;
            // remove attribute eventually added to first text line while opening
            first.onclick = null;
            first.removeAttribute("dy");
            first.removeAttribute("dx");
            // keep only the first line of text
            for(let span of Array.from(txt.children).slice(1)){
                txt.removeChild(span)
            }
        }
        // Put rectangle element in saved original state
        reset_box(){
            let m = this.box_bbox;
            let b = this.box_elt;
            b.x.baseVal.value = m.x;
            b.y.baseVal.value = m.y;
            b.width.baseVal.value = m.width;
            b.height.baseVal.value = m.height;
        }
        highlight_selection(){
            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) {
                spanslength--;
                highlighted_row++;
            }
            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";
        }
        reset_highlight(){
            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
        adjust_box_to_text(){
            let [lmargin, tmargin] = this.margins;
            let m = this.text_elt.getBBox();
            let b = this.box_elt;
            // 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() {
    choose{
        // 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;
        }
        when "count(arg) = 0"{ 
            if "not($text_elt[self::svg:use])"
                error > No argrument 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;
        }
        otherwise {
            |   this.text_elt = id("«$text_elt/@id»");
            |   this.content = [
            foreach "arg" | "«@value»",
            |   ];
        }
    }
    | }
}

emit "declarations:DropDown"
||
function gettext(o) {
    if(typeof(o) == "string"){
        return o;
    }
    return svg_text_to_multiline(o);
};
||