1 /*
   2  * Copyright (c) 2008, 2013, Oracle and/or its affiliates.
   3  * All rights reserved. Use is subject to license terms.
   4  *
   5  * This file is available and licensed under the following license:
   6  *
   7  * Redistribution and use in source and binary forms, with or without
   8  * modification, are permitted provided that the following conditions
   9  * are met:
  10  *
  11  *  - Redistributions of source code must retain the above copyright
  12  *    notice, this list of conditions and the following disclaimer.
  13  *  - Redistributions in binary form must reproduce the above copyright
  14  *    notice, this list of conditions and the following disclaimer in
  15  *    the documentation and/or other materials provided with the distribution.
  16  *  - Neither the name of Oracle Corporation nor the names of its
  17  *    contributors may be used to endorse or promote products derived
  18  *    from this software without specific prior written permission.
  19  *
  20  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
  21  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
  22  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
  23  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
  24  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
  25  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
  26  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  27  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  28  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  29  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  30  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  31  */
  32 package com.javafx.experiments.scheduleapp.pages;
  33 
  34 import static com.javafx.experiments.scheduleapp.ConferenceScheduleApp.*;
  35 import com.javafx.experiments.scheduleapp.TouchClickedEventAvoider;
  36 import com.javafx.experiments.scheduleapp.Page;
  37 import static com.javafx.experiments.scheduleapp.Theme.*;
  38 import com.javafx.experiments.scheduleapp.control.EventPopoverPage;
  39 import com.javafx.experiments.scheduleapp.control.Popover;
  40 import com.javafx.experiments.scheduleapp.control.SearchBox;
  41 import com.javafx.experiments.scheduleapp.data.DataService;
  42 import com.javafx.experiments.scheduleapp.model.Event;
  43 import com.javafx.experiments.scheduleapp.model.Session;
  44 import com.javafx.experiments.scheduleapp.model.SessionTime;
  45 import com.javafx.experiments.scheduleapp.model.SessionType;
  46 import com.javafx.experiments.scheduleapp.model.Track;
  47 import java.text.DateFormat;
  48 import java.text.SimpleDateFormat;
  49 import java.util.ArrayList;
  50 import java.util.Calendar;
  51 import java.util.Comparator;
  52 import java.util.Date;
  53 import java.util.HashMap;
  54 import java.util.LinkedList;
  55 import java.util.List;
  56 import java.util.Map;
  57 import java.util.Set;
  58 import java.util.concurrent.ExecutorService;
  59 import java.util.concurrent.Executors;
  60 import java.util.concurrent.ThreadFactory;
  61 import javafx.beans.InvalidationListener;
  62 import javafx.beans.Observable;
  63 import javafx.beans.property.BooleanProperty;
  64 import javafx.beans.property.SimpleBooleanProperty;
  65 import javafx.beans.value.ChangeListener;
  66 import javafx.beans.value.ObservableValue;
  67 import javafx.collections.FXCollections;
  68 import javafx.collections.ListChangeListener;
  69 import javafx.collections.ListChangeListener.Change;
  70 import javafx.collections.ObservableList;
  71 import javafx.concurrent.Task;
  72 import javafx.concurrent.WorkerStateEvent;
  73 import javafx.event.EventHandler;
  74 import javafx.geometry.VPos;
  75 import javafx.scene.Node;
  76 import javafx.scene.control.Button;
  77 import javafx.scene.control.ListCell;
  78 import javafx.scene.control.ListView;
  79 import javafx.scene.control.Skin;
  80 import javafx.scene.input.MouseEvent;
  81 import javafx.scene.layout.HBox;
  82 import javafx.scene.layout.Priority;
  83 import javafx.scene.layout.Region;
  84 import javafx.scene.paint.Color;
  85 import javafx.scene.shape.Rectangle;
  86 import javafx.scene.text.Font;
  87 import javafx.scene.text.FontWeight;
  88 import javafx.scene.text.Text;
  89 import javafx.util.Callback;
  90 
  91 public class CatalogPage extends Page  implements Callback<ListView<CatalogPage.Row>, ListCell<CatalogPage.Row>>, Runnable, ChangeListener<String>  {
  92     private static DateFormat TIME_FORMAT = new SimpleDateFormat("hh:mma");
  93     private static DateFormat DAY_FORMAT = new SimpleDateFormat("EEEE");
  94     private static final int TIME_COLUMN_WIDTH = 100;
  95     private static final int SLOT_WIDTH = 200;
  96     private static final int SLOT_HEIGHT = 85;
  97     private static final int SLOT_BAR_WIDTH = 5;
  98     private static final int SLOT_BAR_GAP = 5;
  99     private static final int SLOT_GAP = 5;
 100     private static final int SLOT_TEXT_WRAP = SLOT_WIDTH - SLOT_BAR_WIDTH - SLOT_BAR_GAP - SLOT_GAP;
 101     private static final Font TITLE_FONT = BASE_FONT;
 102     private static final Color TITLE_COLOR = DARK_GREY;
 103     private static final Font SPEAKERS_FONT = Font.font(DEFAULT_FONT_NAME, FontWeight.BOLD, 12);
 104     private static final Color SPEAKERS_COLOR = Color.web("#5f5f5f");
 105     private static final Font TIME_FONT = LARGE_FONT;
 106     private static final Font DAY_FONT = BASE_FONT;
 107     private static final int MAX_TITLE_CHARS = 55;
 108     private static final int MAX_SPEAKER_CHARS = 50;
 109 
 110     /**
 111      * The ExecutorService used for running our filter tasks. We could have just created
 112      * a new thread each time, but there really isn't a need for it. In addition, by having
 113      * a single thread executor, we can be sure that no two tasks are operating at the
 114      * same time, which determinism makes it easier for us to efficiently handle updating
 115      * the state of the UI thread during filtering.
 116      */
 117     private static ExecutorService FILTER_EXECUTOR = Executors.newSingleThreadExecutor(new ThreadFactory() {
 118         @Override public Thread newThread(Runnable r) {
 119             Thread th = new Thread(r);
 120             th.setDaemon(true);
 121             th.setName("Catalog Filter Thread");
 122             return th;
 123         }
 124     });
 125 
 126     /**
 127      * The ListView of all of the sessions at the conference. Each row is
 128      * comprised first of a Date, followed by Sessions that are associated
 129      * with the Date / time.
 130      */
 131     private final ListView<Row> list;
 132 
 133     /**
 134      * This button will pop up the session filter popover. The session filter differs
 135      * from the filter bar in that it allows you to filter out entire
 136      * categories of issues, whereas the "search bar" (really a filter bar) is
 137      * based on the text in the sessions (summary, title, speakers).
 138      */
 139     private final Button sessionFilterButton;
 140 
 141     /**
 142      * The session filter is used to store the results of the session filter pane,
 143      * and can filter out sessions passed to it.
 144      */
 145     private SessionFilterCriteria sessionFilter = SessionFilterCriteria.EMPTY;
 146 
 147     /**
 148      * The data items used in the ListView.
 149      */
 150     private final ObservableList<Row> rows = FXCollections.observableArrayList();
 151 
 152     /**
 153      * A list of Rows that have been filtered out of the <code>rows</code>. That is,
 154      * these rows are no longer in the list view.
 155      */
 156     private final List<Row> filtered = new LinkedList<Row>();
 157 
 158     /**
 159      * The search box, used for filtering.
 160      */
 161     private final SearchBox searchBox = new SearchBox();
 162 
 163     /**
 164      * The background task which actually does the filtering. This is moved into a
 165      * background task so as not to cause delays in the GUI.
 166      */
 167     private Task<Runnable> filterTask = null;
 168 
 169     private final HBox searchBar = new HBox(10);
 170 
 171     private final Popover sessionFilterPopover = new Popover();
 172     private final SearchFilterPopoverPage filterPopoverPage;
 173     private final Popover popover;
 174 
 175     /**
 176      * Creats a new catalog filterPopoverPage.
 177      *
 178      * @param dataService    The dataservice to use.
 179      */
 180     public CatalogPage(final Popover popover, final DataService dataService) {
 181         super("Content Catalog", dataService);
 182         this.popover = popover;
 183         sessionFilterPopover.getStyleClass().add("session-popover");
 184         sessionFilterPopover.setPrefWidth(440);
 185         // set pick on bounds to false as we don't want to capture mouse events
 186         // that are meant for the top tabs even though those tabs are in our bounds
 187         // because of the filter button
 188         setPickOnBounds(false);
 189         // create list
 190         list = new ListView<Row>(){
 191             {
 192                 getStyleClass().setAll("twitter-list-view");
 193                 skinClassNameProperty().set("javafx.scene.control.skin.ListViewSkin");
 194                 setCellFactory(CatalogPage.this);
 195             }
 196         };
 197 
 198         if (IS_BEAGLE) {
 199             new TouchClickedEventAvoider(list);
 200         }
 201 
 202         // create filter button
 203         this.filterPopoverPage = new SearchFilterPopoverPage(dataService, this);
 204         sessionFilterButton = new Button();
 205         sessionFilterButton.setId("session-filter-button");
 206         sessionFilterButton.getStyleClass().clear();
 207         sessionFilterButton.setPrefSize(69, 31);
 208         sessionFilterButton.setOnMouseClicked(new EventHandler<MouseEvent>() {
 209             @Override public void handle(MouseEvent e) {
 210                 if (sessionFilterPopover.isVisible()) {
 211                     sessionFilterPopover.hide();
 212                 } else {
 213                     sessionFilterPopover.pushPage(filterPopoverPage);
 214                     sessionFilterPopover.show();
 215                 }
 216             }
 217         });
 218         // create search bar
 219         HBox.setHgrow(searchBox, Priority.ALWAYS);
 220         searchBar.getChildren().addAll(searchBox, sessionFilterButton);
 221         searchBox.textProperty().addListener(this);
 222         searchBox.setFocusTraversable(false);
 223         // add children
 224         getChildren().setAll(searchBar, list, sessionFilterPopover);
 225         // set list to use rows as model
 226         list.setItems(rows);
 227         // populate filterPopoverPage with initial data
 228         buildRows();
 229         // listen for when session time data changes and rebuild rows
 230         dataService.getStartTimes().addListener(new ListChangeListener<Date>() {
 231             @Override public void onChanged(Change<? extends Date> c) {
 232                 buildRows();
 233                 filter();
 234             }
 235         });
 236     }
 237     
 238     private void buildRows() {
 239         final List<Row> items = new ArrayList<>(200);
 240         for (final Date startTime : dataService.getStartTimes()) {
 241             final List<Session> s = dataService.getSessionsAtTimeSlot(startTime);
 242             final List<SessionView> views = new ArrayList<>(s.size());
 243             for (Session session : s) {
 244                 final SessionView view = new SessionView();
 245                 view.session = session;
 246                 views.add(view);
 247             }
 248             final Row row = new Row(startTime, views);
 249             items.add(row);
 250         }
 251         rows.setAll(items);
 252     }
 253     
 254     @Override public void reset() {
 255         // Cause the session filter to clear out
 256         sessionFilterPopover.hide();
 257         filterPopoverPage.reset();
 258         searchBox.setText("");
 259         list.scrollTo(0);
 260     }
 261 
 262     /**
 263      * Called by the search box whenever the text of the search box has changed.
 264      * If there is a filterTask already being executed, then we will cancel it.
 265      * We then create a new filterTask which will process all of the rows and
 266      * then update the UI with the results of the filter operation.
 267      */
 268     @Override public void changed(ObservableValue<? extends String> ov, String oldValue, String newValue) {
 269         sessionFilter = SessionFilterCriteria.withText(searchBox.getText().toLowerCase(), sessionFilter);
 270         filter();
 271     }
 272 
 273     @Override public void run() {
 274         sessionFilter = SessionFilterCriteria.withText(sessionFilter.getText(), filterPopoverPage.getSessionFilterCriteria());
 275         filter();
 276     }
 277 
 278     private void filter() {
 279         // If there is an existing filter task running, then we need to cancel it.
 280         if (filterTask != null && filterTask.isRunning()) {
 281             filterTask.cancel();
 282         }
 283 
 284         // We will not set lastFilter until we have successfully completed a filter operation
 285         final SessionFilterCriteria criteria = sessionFilter;
 286         // The rows that we're going to search through.
 287         final List<Row> allRows = new ArrayList<Row>();
 288         allRows.addAll(rows);
 289         allRows.addAll(filtered);
 290 
 291         // Create a new filterTask
 292         filterTask = new Task<Runnable>() {
 293             @Override protected Runnable call() throws Exception {
 294                 // A map of sessions which need to move from being filtered to being unfiltered
 295                 final Map<Row, List<SessionView>> sessionsToRestore = new HashMap<Row, List<SessionView>>();
 296                 // A map of sessions which need to move to being filtered
 297                 final Map<Row, List<SessionView>> sessionsToFilter = new HashMap<Row, List<SessionView>>();
 298                 // The filtering criteria saves as local variables
 299                 final String text = criteria.getText();
 300                 final Set<Track> tracks = criteria.getTracks();
 301                 final Set<SessionType> types = criteria.getSessionTypes();
 302                 // For each row we needed to consider, inspect all of its session views and
 303                 // filter them appropriately.
 304                 for (Row row : allRows) {
 305                     for (SessionView view : row.sessions) {
 306                         // If the task has been canceled, then bail. This is done on each iteration
 307                         // so that we do as little work after the thread has been canceled a possible
 308                         if (isCancelled()) return null;
 309                         // If the view is presently filtered, but should no longer be filtered,
 310                         // then add it to the sessionsToRestore map.
 311                         final Session s = view.session;
 312                         final Track t = s.getTrack();
 313                         final SessionType st = s.getSessionType();
 314 
 315                         final boolean shouldKeep = (s.getTitle().toLowerCase().contains(text) ||
 316                                 s.getSpeakersDisplay().toLowerCase().contains(text) ||
 317                                 s.getSummary().toLowerCase().contains(text) ||
 318                                 s.getAbbreviation().toLowerCase().contains(text)) &&
 319                                 (!(t != null && tracks.contains(t)) &&
 320                             !(st != null && types.contains(st)));
 321 
 322                         if (view.filtered.get() && shouldKeep) {
 323                             // We do not need to filter this one any more
 324                             List<SessionView> views = sessionsToRestore.get(row);
 325                             if (views == null) {
 326                                 views = new ArrayList<SessionView>();
 327                                 sessionsToRestore.put(row, views);
 328                             }
 329                             views.add(view);
 330                         // If the view is presently not being filtered, but should be, then add
 331                         // it to the sessionsToFilter map.
 332                         } else if (!view.filtered.get() && !shouldKeep) {
 333                             // We need to filter this one
 334                             List<SessionView> views = sessionsToFilter.get(row);
 335                             if (views == null) {
 336                                 views = new ArrayList<SessionView>();
 337                                 sessionsToFilter.put(row, views);
 338                             }
 339                             views.add(view);
 340                         }
 341                     }
 342                 }
 343 
 344                 return new Runnable() {
 345                     @Override public void run() {
 346                         // Hide all sessions that have been filtered.
 347                         for (Map.Entry<Row, List<SessionView>> entry : sessionsToFilter.entrySet()) {
 348                             final Row row = entry.getKey();
 349                             final List<SessionView> views = entry.getValue();
 350                             for (SessionView view : views) {
 351                                 assert view.filtered.get() == false;
 352                                 assert row.numVisible > 0;
 353 
 354                                 row.numVisible--;
 355                                 view.filtered.set(true);
 356 
 357                                 if (row.numVisible == 0) {
 358                                     filtered.add(row);
 359                                     rows.remove(row);
 360                                 }
 361                             }
 362                         }
 363 
 364                         // Restore all the sessions that have been restored.
 365                         for (Map.Entry<Row, List<SessionView>> entry : sessionsToRestore.entrySet()) {
 366                             final Row row = entry.getKey();
 367                             final List<SessionView> views = entry.getValue();
 368                             for (SessionView view : views) {
 369                                 assert view.filtered.get() == true;
 370                                 assert row.numVisible >= 0;
 371 
 372                                 if (row.numVisible == 0) {
 373                                     filtered.remove(row);
 374                                     rows.add(row);
 375                                 }
 376 
 377                                 row.numVisible++;
 378                                 view.filtered.set(false);
 379                             }
 380                         }
 381 
 382                         // Resort the rows so that the dates are all correct
 383                         FXCollections.sort(rows, new Comparator<Row>() {
 384                             @Override public int compare(Row o1, Row o2) {
 385                                 return o1.date.compareTo(o2.date);
 386                             }
 387                         });
 388                     }
 389                 };
 390             }
 391         };
 392         filterTask.setOnSucceeded(new EventHandler<WorkerStateEvent>() {
 393             @Override public void handle(WorkerStateEvent event) {
 394                 assert !filterTask.isCancelled();
 395                 Runnable r = filterTask.getValue();
 396                 if (r != null) {
 397                     filterTask.getValue().run();
 398                 }
 399             }
 400         });
 401         filterTask.setOnFailed(new EventHandler<WorkerStateEvent>() {
 402             @Override public void handle(WorkerStateEvent event) {
 403                 // Debugging
 404                 event.getSource().getException().printStackTrace();
 405             }
 406         });
 407 
 408         // Execute the filterTask on this single-threaded Executor
 409         FILTER_EXECUTOR.submit(filterTask);
 410     }
 411 
 412     @Override protected void layoutChildren() {
 413         final int w = (int)getWidth() - 24;
 414         final int h = (int)getHeight() - 24;
 415 
 416         sessionFilterPopover.autosize();
 417         sessionFilterPopover.setLayoutX(w - sessionFilterPopover.prefWidth(-1) + 17);
 418         sessionFilterPopover.setLayoutY(58);
 419         searchBar.resize(w,30);
 420         searchBar.setLayoutX(12);
 421         searchBar.setLayoutY(12);
 422 
 423         list.resize(w,h - 42);
 424         list.setLayoutX(12);
 425         list.setLayoutY(53);
 426     }
 427 
 428     /**
 429      * Part of the "View-Model", the row represents a row in the list view.
 430      * Each row is made up of a Date and a number of "SessionView" objects.
 431      * Each SessionView is a "View-Model" for a Session.
 432      */
 433     public class Row {
 434         final Date date;
 435         final List<SessionView> sessions;
 436         int numVisible;
 437 
 438         Row(Date date, List<SessionView> sessions) {
 439             this.date = date;
 440             this.sessions = sessions;
 441             for (SessionView v : sessions) {
 442                 if (!v.filtered.get()) numVisible++;
 443             }
 444         }
 445     }
 446 
 447     /**
 448      * The "View-Model" for a Session. This contains a single observable Boolean property
 449      * called "filtered", and a reference to the session that this view represents.
 450      */
 451     class SessionView {
 452         BooleanProperty filtered = new SimpleBooleanProperty(this, "filtered", false);
 453         Session session;
 454     }
 455 
 456     @Override public ListCell<Row> call(ListView<Row> listView) {
 457         return new TimeSlotListCell();
 458     }
 459 
 460     static int counter = 0;
 461     private class TimeSlotListCell extends ListCell<Row> implements Skin<TimeSlotListCell> {
 462         private Text timeText = new Text();
 463         private Text dayText = new Text();
 464         private List<Tile> tiles = new ArrayList<Tile>();
 465         private String name = "TimeSlotListCell-" + counter++;
 466 
 467         @Override public String toString() { return name; }
 468 
 469         private TimeSlotListCell() {
 470             super();
 471             setSkin(this);
 472             getStyleClass().clear();
 473             timeText.setFont(TIME_FONT);
 474             timeText.setFill(BLUE);
 475             dayText.setFont(DAY_FONT);
 476             dayText.setFill(BLUE);
 477             getChildren().add(dayText);
 478         }
 479 
 480         @Override protected double computePrefWidth(double height) {
 481             return 330;
 482         }
 483 
 484         // TODO ListView is broken -- it doesn't support content bias! Workaround: return the width as the pref width
 485         @Override protected double computePrefHeight(double width) {
 486             final Row row = getItem();
 487             if (row == null || row.numVisible == 0) return (getIndex() == 0 ? 5 : 0) + SLOT_HEIGHT + 5;
 488 
 489             // We have a row and it has sessions. We need to figure out how many tiles
 490             // can be placed in the session layout area per row (right of the time column).
 491             // That will tell us how many rows of sessions we will have, which then gives us
 492             // a pref height that we can return.
 493             if (width == -1) width = getWidth();
 494             // TODO normally insets need to be taken into account but we're ignoring them
 495             final double sessionAreaWidth = width - TIME_COLUMN_WIDTH;
 496             final int tilesPerRow = (int) (sessionAreaWidth / (SLOT_WIDTH + SLOT_GAP));
 497             if (tilesPerRow == 0) {
 498                 return SLOT_HEIGHT + (getIndex() == 0 ? 10 : 5);
 499             }
 500             final int numRows = (int) Math.ceil(row.numVisible / (double) tilesPerRow);
 501             // TODO did I get this right?
 502             return (getIndex() == 0 ? 5 : 0) + (numRows > 0 ? (numRows * (SLOT_HEIGHT + SLOT_GAP)) : 5);
 503         }
 504 
 505         @Override protected void layoutChildren() {
 506             final int top = (getIndex() == 0 ? 5 : 0);
 507             timeText.relocate(5, top + 5);
 508             dayText.relocate(5, top + 30);
 509 
 510             final Row row = getItem();
 511             final int numTiles = row == null ? 0 : row.numVisible;
 512             final double width = getWidth();
 513             final double sessionAreaWidth = width - TIME_COLUMN_WIDTH;
 514             final int tilesPerRow = (int) (sessionAreaWidth / (SLOT_WIDTH + SLOT_GAP));
 515             final int numRows = tilesPerRow == 0 ? 0 : (int) Math.ceil(numTiles / (double) tilesPerRow);
 516 
 517             int tileIndex = 0;
 518             for (int r=0; r<numRows; r++) {
 519                 for (int c=0; c<tilesPerRow && tileIndex < tiles.size();) {
 520                     Tile tile = tiles.get(tileIndex++);
 521                     if (tile.isVisible()) {
 522                         double x = c * (SLOT_WIDTH + SLOT_GAP) + TIME_COLUMN_WIDTH;
 523                         double y = r * (SLOT_HEIGHT + SLOT_GAP) + 5;
 524                         tile.resizeRelocate(x, y, SLOT_WIDTH, SLOT_HEIGHT);
 525                         c++;
 526                     }
 527                 }
 528             }
 529         }
 530 
 531         // CELL METHODS
 532         @Override protected void updateItem(Row item, boolean empty) {
 533             super.updateItem(item, empty);
 534 
 535             if (!empty) {
 536                 List<SessionView> views = item.sessions;
 537 
 538                 // Unlink all tiles
 539                 for (int i=0; i<tiles.size(); i++) {
 540                     Tile tile = tiles.get(i);
 541                     tile.unlink();
 542                 }
 543 
 544                 // Add any missing tiles
 545                 for (int i=tiles.size(); i<views.size(); i++) {
 546                     Tile tile = new Tile(this);
 547                     tiles.add(tile);
 548                     getChildren().add(tile);
 549                 }
 550 
 551                 // Update all tiles
 552                 for (int i=0; i<views.size(); i++) {
 553                     SessionView view = views.get(i);
 554                     Tile tile = tiles.get(i);
 555                     tile.update(view);
 556                 }
 557 
 558                 // update date text
 559                 if (item.date == null) {
 560                     timeText.setVisible(false);
 561                     dayText.setVisible(false);
 562                 } else {
 563                     final Date date = item.date;
 564                     timeText.setText(TIME_FORMAT.format(date));
 565                     dayText.setText(DAY_FORMAT.format(date).toUpperCase());
 566                     timeText.setVisible(true);
 567                     dayText.setVisible(true);
 568                 }
 569             } else {
 570                 timeText.setVisible(false);
 571                 dayText.setVisible(false);
 572                 for (Tile tile : tiles) {
 573                     tile.unlink();
 574                 }
 575             }
 576         }
 577 
 578         // SKIN METHODS
 579         @Override public TimeSlotListCell getSkinnable() { return this; }
 580         @Override public Node getNode() { return timeText; }
 581         @Override public void dispose() {}
 582     }
 583 
 584     class Tile extends Region implements EventHandler<MouseEvent>, InvalidationListener {
 585         private Text title;
 586         private Text speaker;
 587         private Rectangle bar;
 588         private SessionView view; // Doubly-linked between Tile & SessionView.
 589         private TimeSlotListCell cell;
 590 
 591         Tile(TimeSlotListCell cell) {
 592             this.cell = cell;
 593             title = new Text();
 594             title.setWrappingWidth(SLOT_TEXT_WRAP);
 595             title.setFont(TITLE_FONT);
 596             title.setFill(TITLE_COLOR);
 597             title.setTextOrigin(VPos.TOP);
 598             speaker = new Text();
 599             speaker.setFont(SPEAKERS_FONT);
 600             speaker.setFill(SPEAKERS_COLOR);
 601             speaker.setTextOrigin(VPos.TOP);
 602             bar = new Rectangle(SLOT_BAR_WIDTH, 0);
 603             setOnMouseClicked(this);
 604             getChildren().addAll(bar, title, speaker);
 605         }
 606 
 607         void update(SessionView view) {
 608             this.view = view;
 609             view.filtered.addListener(this);
 610             Session session = view.session;
 611             String s = session.getAbbreviation() + " :: " + session.getTitle();
 612             if (s.length() > MAX_TITLE_CHARS) s = s.substring(0, MAX_TITLE_CHARS).trim() + "...";
 613             title.setText(s);
 614             s = session.getSpeakersDisplay();
 615             if (s.length() > MAX_SPEAKER_CHARS) s = s.substring(0, MAX_SPEAKER_CHARS).trim() + "...";
 616             speaker.setText(s);
 617             bar.setFill(Color.web(session.getTrack().getColor()));
 618             setVisible(!view.filtered.get());
 619         }
 620 
 621         @Override public void invalidated(Observable observable) {
 622             setVisible(!this.view.filtered.get());
 623             cell.requestLayout();
 624         }
 625 
 626         void unlink() {
 627             if (this.view != null) {
 628                 this.view.filtered.removeListener(this);
 629             }
 630             this.view = null;
 631             setVisible(false);
 632         }
 633 
 634         @Override protected double computePrefWidth(double height) {
 635             return SLOT_WIDTH;
 636         }
 637 
 638         @Override protected double computePrefHeight(double width) {
 639             return SLOT_HEIGHT;
 640         }
 641 
 642         @Override protected void layoutChildren() {
 643             // I'm ignoring the insets
 644             bar.setHeight(getHeight());
 645 
 646             final double wrappingWidth = getWidth() - SLOT_BAR_WIDTH - SLOT_BAR_GAP;
 647             title.setWrappingWidth(wrappingWidth);
 648             speaker.setWrappingWidth(wrappingWidth);
 649 
 650             title.setX(SLOT_BAR_WIDTH + SLOT_BAR_GAP);
 651             speaker.setX(SLOT_BAR_WIDTH + SLOT_BAR_GAP);
 652             speaker.setY(title.prefHeight(wrappingWidth));
 653         }
 654 
 655         @Override public void handle(MouseEvent mouseEvent) {
 656             // find SessionTime
 657             if (view == null || view.session == null) return;
 658             Session session = view.session;
 659             long startTime = cell.getItem().date.getTime();
 660             SessionTime sessionTime = null;
 661             for(SessionTime st : session.getSessionTimes()) {
 662                 if (startTime == st.getStart().getTime()) {
 663                     sessionTime = st;
 664                     break;
 665                 }
 666             }
 667             // get/create event
 668             Event event = sessionTime.getEvent();
 669             if (event == null) {
 670                 Calendar calendar = Calendar.getInstance();
 671                 calendar.setTime(sessionTime.getStart());
 672                 calendar.add(Calendar.MINUTE, sessionTime.getLength());
 673                 event = new Event(session, sessionTime.getStart(), calendar.getTime(), sessionTime);
 674             }
 675             // show popover
 676             EventPopoverPage page = new EventPopoverPage(dataService, event, false);
 677             popover.clearPages();
 678             popover.pushPage(page);
 679             popover.show();
 680         }
 681     }
 682 }