001package polaris.sdk.ui.controls;
002import polaris.sdk.ui.*;
003import java.util.*;
004import java.util.function.IntConsumer;
005
006/** A selectable list with one shared inset model for paint, input and scroll limits. */
007public class ListBox extends Widget {
008 protected final List<String> items=new ArrayList<String>();
009 protected int selected=-1,hoverRow=-1;
010 protected float visualScroll,scroll,rowHeight=32;
011 protected float padding=Spacing.GAP,rowGap=4;
012 protected boolean glass=true;
013 protected IntConsumer selection,doubleClick;
014 private boolean draggingScrollbar;private float scrollbarGrab;
015 private static final float SCROLL_GUTTER=12,SCROLL_WIDTH=3;
016 public ListBox(){w=240;h=160;focusable=true;}
017 public ListBox(String... values){this();items(values);}
018 /** Disable only the list's own surface. Selection still changes its glass material. */
019 public ListBox glass(boolean value){glass=value;return this;}
020 public boolean glass(){return glass;}
021 /** Equal outer insets on all four sides. A gutter is added only if a scrollbar is visible. */
022 public ListBox padding(float value){if(!Float.isFinite(value)||value<0)throw new IllegalArgumentException("padding");padding=value;return this;}
023 public float padding(){return padding;}
024 public ListBox rowGap(float value){if(!Float.isFinite(value)||value<0||value>=rowHeight)throw new IllegalArgumentException("row gap must be smaller than row height");rowGap=value;return this;}
025 public float rowGap(){return rowGap;}
026 /** Row pitch includes the row gap. */
027 public ListBox rowHeight(float value){if(!Float.isFinite(value)||value<=rowGap)throw new IllegalArgumentException("row height");rowHeight=value;return this;}
028 public float rowHeight(){return rowHeight;}
029 public ListBox items(String... values){items.clear();if(values!=null)for(String s:values)items.add(s==null?"":s);selected=items.isEmpty()?-1:Math.min(Math.max(0,selected),items.size()-1);scroll=visualScroll=0;snapMotion("scroll",0);hoverRow=-1;return this;}
030 public ListBox items(java.util.List<String> values){return items(values.toArray(new String[0]));}
031 public java.util.List<String> items(){return Collections.unmodifiableList(items);}
032 public int selectedIndex(){return selected;}
033 public String selectedItem(){return selected>=0&&selected<items.size()?items.get(selected):"";}
034 public ListBox onSelection(IntConsumer c){selection=c;return this;}
035 public ListBox onDoubleClick(IntConsumer c){doubleClick=c;return this;}
036 public void select(int index){if(index<-1||index>=items.size())throw new IllegalArgumentException("selection");selected=index;ensureVisible();changed();if(selection!=null)selection.accept(index);}
037 protected float contentHeight(){return items.isEmpty()?0:items.size()*rowHeight-rowGap;}
038 protected float viewportHeight(){return Math.max(0,h-2*padding);}
039 protected float maxScroll(){return Math.max(0,contentHeight()-viewportHeight());}
040 public boolean hasScrollbar(){return maxScroll()>.001f;}
041 /** Absolute logical content viewport; useful for consistent custom list renderers. */
042 public Rect contentBounds(){return new Rect(ax+padding,ay+padding,Math.max(0,w-2*padding-(hasScrollbar()?SCROLL_GUTTER:0)),viewportHeight());}
043 /** Actual shown row, including continuous scroll animation. The gap is not clickable. */
044 public Rect rowBounds(int index){if(index<0||index>=items.size())throw new IllegalArgumentException("row");Rect c=contentBounds();return new Rect(c.x,c.y+index*rowHeight-visualScroll,c.w,Math.max(0,rowHeight-rowGap));}
045 protected void ensureVisible(){if(selected>=0){float top=selected*rowHeight,bottom=top+rowHeight-rowGap;if(top<scroll)scroll=top;if(bottom>scroll+viewportHeight())scroll=bottom-viewportHeight();}scroll=Math.max(0,Math.min(maxScroll(),scroll));}
046 @Override protected void layoutChildren(){scroll=Math.max(0,Math.min(maxScroll(),scroll));visualScroll=Math.max(0,Math.min(maxScroll(),motion("scroll",scroll,29,.97f)));}
047 @Override protected void paint(Painter p){
048  if(glass)surface(p);
049  // The outer surface keeps its own AA edge. Only content is intersected with its outline.
050  pushContentClip(p);
051  try{
052   Rect c=contentBounds(),inner=c.intersect(clip);p.clip(inner);
053   int first=Math.max(0,(int)Math.floor(visualScroll/rowHeight));
054   int last=Math.min(items.size(),first+(int)Math.ceil(c.h/rowHeight)+2);
055   float rh=Math.max(0,rowHeight-rowGap),rr=Math.min(theme().radius,rh/2);
056   if(selected>=0){float yy=c.y+motion("selectionY",selected*rowHeight,25,.77f)-visualScroll;glassPart(p,c.x,yy,c.w,rh,rr,theme().selectedTint,theme().accent,.26f,theme().blur);}
057   if(hovered&&hoverRow>=first&&hoverRow<last&&hoverRow!=selected){Rect row=rowBounds(hoverRow);glassPart(p,row.x,row.y,row.w,row.h,rr,theme().hoverTint,theme().surface,0,theme().blur);}
058   for(int i=first;i<last;i++){
059    Rect row=rowBounds(i);float indent=Math.min(Spacing.GAP,row.w/2);
060    p.clip(new Rect(row.x+indent,row.y,Math.max(0,row.w-2*indent),row.h).intersect(inner));
061    p.textWeight(items.get(i),row.x+indent,row.y+(rh-13)/2,13,ink(),i==selected?500:400);
062   }
063   p.clip(clip);paintScrollbar(p);
064  }finally{popContentClip(p);p.clip(clip);}
065 }
066 protected void paintScrollbar(Painter p){if(hasScrollbar()){Rect t=scrollbarTrack();float size=thumbLength();p.fill(t.x,t.y+(t.h-size)*visualScroll/maxScroll(),t.w,size,t.w/2,Theme.alpha(theme().text,.22f));}}
067 private Rect scrollbarTrack(){return new Rect(ax+w-padding-SCROLL_WIDTH,ay+padding,SCROLL_WIDTH,viewportHeight());}
068 private float thumbLength(){float height=viewportHeight();return Math.min(height,Math.max(18,height*height/Math.max(1,contentHeight())));}
069 private boolean scrollbarHit(float px,float py){return hasScrollbar()&&px>=ax+w-padding-SCROLL_GUTTER&&px<ax+w-padding&&py>=ay+padding&&py<ay+h-padding;}
070 private void dragScrollbar(float py){Rect track=scrollbarTrack();float travel=track.h-thumbLength();scroll=travel>0?Math.max(0,Math.min(maxScroll(),(py-track.y-scrollbarGrab)/travel*maxScroll())):0;}
071 private int rowAt(float px,float py){Rect c=contentBounds();if(!c.contains(px,py))return -1;float local=py-c.y+visualScroll;if(local<0)return -1;int index=(int)Math.floor(local/rowHeight);return index>=0&&index<items.size()&&local-index*rowHeight<rowHeight-rowGap?index:-1;}
072 @Override public void event(UiEvent e){
073  if(e.type==UiEvent.Type.MOVE){hoverRow=rowAt(e.x,e.y);if(draggingScrollbar){dragScrollbar(e.y);e.handled=true;}}
074  else if(e.type==UiEvent.Type.DOWN&&e.button==1){
075   if(scrollbarHit(e.x,e.y)){Rect track=scrollbarTrack();float size=thumbLength(),top=track.y+(track.h-size)*visualScroll/maxScroll();boolean inside=e.y>=top&&e.y<=top+size;scrollbarGrab=inside?e.y-top:size/2;draggingScrollbar=true;if(!inside)dragScrollbar(e.y);e.handled=true;return;}
076   int index=rowAt(e.x,e.y);if(index>=0){select(index);if(e.clicks>=2&&doubleClick!=null)doubleClick.accept(index);pressed=true;}e.handled=true;
077  }else if(e.type==UiEvent.Type.UP&&e.button==1){if(draggingScrollbar){dragScrollbar(e.y);draggingScrollbar=false;}else if(pressed&&contains(e.x,e.y))activate();pressed=false;}
078  else if(e.type==UiEvent.Type.BLUR){draggingScrollbar=false;pressed=false;}
079  else if(e.type==UiEvent.Type.WHEEL){scroll=Math.max(0,Math.min(maxScroll(),scroll+e.wheel*rowHeight*2));e.handled=true;}
080  else if(e.type==UiEvent.Type.KEY){int i=selected;if(e.key==38)i--;else if(e.key==40)i++;else if(e.key==36)i=0;else if(e.key==35)i=items.size()-1;else if(e.key==10){activate();if(doubleClick!=null&&selected>=0)doubleClick.accept(selected);e.handled=true;return;}else return;if(!items.isEmpty())select(Math.max(0,Math.min(items.size()-1,i)));e.handled=true;}
081 }
082}