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}