// 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);
};
||