diff options
Diffstat (limited to 'src/CheckerboardLayout.vala')
-rw-r--r-- | src/CheckerboardLayout.vala | 1872 |
1 files changed, 1872 insertions, 0 deletions
diff --git a/src/CheckerboardLayout.vala b/src/CheckerboardLayout.vala new file mode 100644 index 0000000..398152e --- /dev/null +++ b/src/CheckerboardLayout.vala @@ -0,0 +1,1872 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +private class CheckerboardItemText { + private static int one_line_height = 0; + + private string text; + private bool marked_up; + private Pango.Alignment alignment; + private Pango.Layout layout = null; + private bool single_line = true; + private int height = 0; + + public Gdk.Rectangle allocation = Gdk.Rectangle(); + + public CheckerboardItemText(string text, Pango.Alignment alignment = Pango.Alignment.LEFT, + bool marked_up = false) { + this.text = text; + this.marked_up = marked_up; + this.alignment = alignment; + + single_line = is_single_line(); + } + + private bool is_single_line() { + return !String.contains_char(text, '\n'); + } + + public bool is_marked_up() { + return marked_up; + } + + public bool is_set_to(string text, bool marked_up, Pango.Alignment alignment) { + return (this.marked_up == marked_up && this.alignment == alignment && this.text == text); + } + + public string get_text() { + return text; + } + + public int get_height() { + if (height == 0) + update_height(); + + return height; + } + + public Pango.Layout get_pango_layout(int max_width = 0) { + if (layout == null) + create_pango(); + + if (max_width > 0) + layout.set_width(max_width * Pango.SCALE); + + return layout; + } + + public void clear_pango_layout() { + layout = null; + } + + private void update_height() { + if (one_line_height != 0 && single_line) + height = one_line_height; + else + create_pango(); + } + + private void create_pango() { + // create layout for this string and ellipsize so it never extends past its laid-down width + layout = AppWindow.get_instance().create_pango_layout(null); + if (!marked_up) + layout.set_text(text, -1); + else + layout.set_markup(text, -1); + + layout.set_ellipsize(Pango.EllipsizeMode.END); + layout.set_alignment(alignment); + + // getting pixel size is expensive, and we only need the height, so use cached values + // whenever possible + if (one_line_height != 0 && single_line) { + height = one_line_height; + } else { + int width; + layout.get_pixel_size(out width, out height); + + // cache first one-line height discovered + if (one_line_height == 0 && single_line) + one_line_height = height; + } + } +} + +public abstract class CheckerboardItem : ThumbnailView { + // Collection properties CheckerboardItem understands + // SHOW_TITLES (bool) + public const string PROP_SHOW_TITLES = "show-titles"; + // SHOW_COMMENTS (bool) + public const string PROP_SHOW_COMMENTS = "show-comments"; + // SHOW_SUBTITLES (bool) + public const string PROP_SHOW_SUBTITLES = "show-subtitles"; + + public const int FRAME_WIDTH = 8; + public const int LABEL_PADDING = 4; + public const int BORDER_WIDTH = 1; + + public const int SHADOW_RADIUS = 4; + public const float SHADOW_INITIAL_ALPHA = 0.5f; + + public const int TRINKET_SCALE = 12; + public const int TRINKET_PADDING = 1; + + public const int BRIGHTEN_SHIFT = 0x18; + + public Dimensions requisition = Dimensions(); + public Gdk.Rectangle allocation = Gdk.Rectangle(); + + private bool exposure = false; + private CheckerboardItemText? title = null; + private bool title_visible = true; + private CheckerboardItemText? comment = null; + private bool comment_visible = true; + private CheckerboardItemText? subtitle = null; + private bool subtitle_visible = false; + private Gdk.Pixbuf pixbuf = null; + private Gdk.Pixbuf display_pixbuf = null; + private Gdk.Pixbuf brightened = null; + private Dimensions pixbuf_dim = Dimensions(); + private int col = -1; + private int row = -1; + private int horizontal_trinket_offset = 0; + + public CheckerboardItem(ThumbnailSource source, Dimensions initial_pixbuf_dim, string title, string? comment, + bool marked_up = false, Pango.Alignment alignment = Pango.Alignment.LEFT) { + base(source); + + pixbuf_dim = initial_pixbuf_dim; + this.title = new CheckerboardItemText(title, alignment, marked_up); + // on the checkboard page we display the comment in + // one line, i.e., replacing all newlines with spaces. + // that means that the display will contain "..." if the comment + // is too long. + // warning: changes here have to be done in set_comment, too! + if (comment != null) + this.comment = new CheckerboardItemText(comment.replace("\n", " "), alignment, + marked_up); + + // Don't calculate size here, wait for the item to be assigned to a ViewCollection + // (notify_membership_changed) and calculate when the collection's property settings + // are known + } + + public override string get_name() { + return (title != null) ? title.get_text() : base.get_name(); + } + + public string get_title() { + return (title != null) ? title.get_text() : ""; + } + + public string get_comment() { + return (comment != null) ? comment.get_text() : ""; + } + + public void set_title(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (title != null && title.is_set_to(text, marked_up, alignment)) + return; + + title = new CheckerboardItemText(text, alignment, marked_up); + + if (title_visible) { + recalc_size("set_title"); + notify_view_altered(); + } + } + + public void clear_title() { + if (title == null) + return; + + title = null; + + if (title_visible) { + recalc_size("clear_title"); + notify_view_altered(); + } + } + + private void set_title_visible(bool visible) { + if (title_visible == visible) + return; + + title_visible = visible; + + recalc_size("set_title_visible"); + notify_view_altered(); + } + + public void set_comment(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (comment != null && comment.is_set_to(text, marked_up, alignment)) + return; + + comment = new CheckerboardItemText(text.replace("\n", " "), alignment, marked_up); + + if (comment_visible) { + recalc_size("set_comment"); + notify_view_altered(); + } + } + + public void clear_comment() { + if (comment == null) + return; + + comment = null; + + if (comment_visible) { + recalc_size("clear_comment"); + notify_view_altered(); + } + } + + private void set_comment_visible(bool visible) { + if (comment_visible == visible) + return; + + comment_visible = visible; + + recalc_size("set_comment_visible"); + notify_view_altered(); + } + + + public string get_subtitle() { + return (subtitle != null) ? subtitle.get_text() : ""; + } + + public void set_subtitle(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (subtitle != null && subtitle.is_set_to(text, marked_up, alignment)) + return; + + subtitle = new CheckerboardItemText(text, alignment, marked_up); + + if (subtitle_visible) { + recalc_size("set_subtitle"); + notify_view_altered(); + } + } + + public void clear_subtitle() { + if (subtitle == null) + return; + + subtitle = null; + + if (subtitle_visible) { + recalc_size("clear_subtitle"); + notify_view_altered(); + } + } + + private void set_subtitle_visible(bool visible) { + if (subtitle_visible == visible) + return; + + subtitle_visible = visible; + + recalc_size("set_subtitle_visible"); + notify_view_altered(); + } + + protected override void notify_membership_changed(DataCollection? collection) { + bool title_visible = (bool) get_collection_property(PROP_SHOW_TITLES, true); + bool comment_visible = (bool) get_collection_property(PROP_SHOW_COMMENTS, true); + bool subtitle_visible = (bool) get_collection_property(PROP_SHOW_SUBTITLES, false); + + bool altered = false; + if (this.title_visible != title_visible) { + this.title_visible = title_visible; + altered = true; + } + + if (this.comment_visible != comment_visible) { + this.comment_visible = comment_visible; + altered = true; + } + + if (this.subtitle_visible != subtitle_visible) { + this.subtitle_visible = subtitle_visible; + altered = true; + } + + if (altered || !requisition.has_area()) { + recalc_size("notify_membership_changed"); + notify_view_altered(); + } + + base.notify_membership_changed(collection); + } + + protected override void notify_collection_property_set(string name, Value? old, Value val) { + switch (name) { + case PROP_SHOW_TITLES: + set_title_visible((bool) val); + break; + + case PROP_SHOW_COMMENTS: + set_comment_visible((bool) val); + break; + + case PROP_SHOW_SUBTITLES: + set_subtitle_visible((bool) val); + break; + } + + base.notify_collection_property_set(name, old, val); + } + + // The alignment point is the coordinate on the y-axis (relative to the top of the + // CheckerboardItem) which this item should be aligned to. This allows for + // bottom-alignment along the bottom edge of the thumbnail. + public int get_alignment_point() { + return FRAME_WIDTH + BORDER_WIDTH + pixbuf_dim.height; + } + + public virtual void exposed() { + exposure = true; + } + + public virtual void unexposed() { + exposure = false; + + if (title != null) + title.clear_pango_layout(); + + if (comment != null) + comment.clear_pango_layout(); + + if (subtitle != null) + subtitle.clear_pango_layout(); + } + + public virtual bool is_exposed() { + return exposure; + } + + public bool has_image() { + return pixbuf != null; + } + + public Gdk.Pixbuf? get_image() { + return pixbuf; + } + + public void set_image(Gdk.Pixbuf pixbuf) { + this.pixbuf = pixbuf; + display_pixbuf = pixbuf; + pixbuf_dim = Dimensions.for_pixbuf(pixbuf); + + recalc_size("set_image"); + notify_view_altered(); + } + + public void clear_image(Dimensions dim) { + bool had_image = pixbuf != null; + + pixbuf = null; + display_pixbuf = null; + pixbuf_dim = dim; + + recalc_size("clear_image"); + + if (had_image) + notify_view_altered(); + } + + public static int get_max_width(int scale) { + // width is frame width (two sides) + frame padding (two sides) + width of pixbuf (text + // never wider) + return (FRAME_WIDTH * 2) + scale; + } + + private void recalc_size(string reason) { + Dimensions old_requisition = requisition; + + // only add in the text heights if they're displayed + int title_height = (title != null && title_visible) + ? title.get_height() + LABEL_PADDING : 0; + int comment_height = (comment != null && comment_visible) + ? comment.get_height() + LABEL_PADDING : 0; + int subtitle_height = (subtitle != null && subtitle_visible) + ? subtitle.get_height() + LABEL_PADDING : 0; + + // width is frame width (two sides) + frame padding (two sides) + width of pixbuf + // (text never wider) + requisition.width = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + pixbuf_dim.width; + + // height is frame width (two sides) + frame padding (two sides) + height of pixbuf + // + height of text + label padding (between pixbuf and text) + requisition.height = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + + pixbuf_dim.height + title_height + comment_height + subtitle_height; + +#if TRACE_REFLOW_ITEMS + debug("recalc_size %s: %s title_height=%d comment_height=%d subtitle_height=%d requisition=%s", + get_source().get_name(), reason, title_height, comment_height, subtitle_height, + requisition.to_string()); +#endif + + if (!requisition.approx_equals(old_requisition)) { +#if TRACE_REFLOW_ITEMS + debug("recalc_size %s: %s notifying geometry altered", get_source().get_name(), reason); +#endif + notify_geometry_altered(); + } + } + + protected static Dimensions get_border_dimensions(Dimensions object_dim, int border_width) { + Dimensions dimensions = Dimensions(); + dimensions.width = object_dim.width + (border_width * 2); + dimensions.height = object_dim.height + (border_width * 2); + return dimensions; + } + + protected static Gdk.Point get_border_origin(Gdk.Point object_origin, int border_width) { + Gdk.Point origin = Gdk.Point(); + origin.x = object_origin.x - border_width; + origin.y = object_origin.y - border_width; + return origin; + } + + protected virtual void paint_shadow(Cairo.Context ctx, Dimensions dimensions, Gdk.Point origin, + int radius, float initial_alpha) { + double rgb_all = 0.0; + + // top right corner + paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + radius, rgb_all, radius, + initial_alpha, -0.5 * Math.PI, 0); + // bottom right corner + paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + dimensions.height, rgb_all, + radius, initial_alpha, 0, 0.5 * Math.PI); + // bottom left corner + paint_shadow_in_corner(ctx, origin.x + radius, origin.y + dimensions.height, rgb_all, radius, + initial_alpha, 0.5 * Math.PI, Math.PI); + + // left right + Cairo.Pattern lr = new Cairo.Pattern.linear(0, origin.y + dimensions.height, + 0, origin.y + dimensions.height + radius); + lr.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + lr.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); + ctx.set_source(lr); + ctx.rectangle(origin.x + radius, origin.y + dimensions.height, dimensions.width - radius, radius); + ctx.fill(); + + // top down + Cairo.Pattern td = new Cairo.Pattern.linear(origin.x + dimensions.width, + 0, origin.x + dimensions.width + radius, 0); + td.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + td.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); + ctx.set_source(td); + ctx.rectangle(origin.x + dimensions.width, origin.y + radius, + radius, dimensions.height - radius); + ctx.fill(); + } + + protected void paint_shadow_in_corner(Cairo.Context ctx, int x, int y, + double rgb_all, float radius, float initial_alpha, double arc1, double arc2) { + Cairo.Pattern p = new Cairo.Pattern.radial(x, y, 0, x, y, radius); + p.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + p.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0); + ctx.set_source(p); + ctx.move_to(x, y); + ctx.arc(x, y, radius, arc1, arc2); + ctx.close_path(); + ctx.fill(); + } + + protected virtual void paint_border(Cairo.Context ctx, Dimensions object_dimensions, + Gdk.Point object_origin, int border_width) { + if (border_width == 1) { + ctx.rectangle(object_origin.x - border_width, object_origin.y - border_width, + object_dimensions.width + (border_width * 2), + object_dimensions.height + (border_width * 2)); + ctx.fill(); + } else { + Dimensions dimensions = get_border_dimensions(object_dimensions, border_width); + Gdk.Point origin = get_border_origin(object_origin, border_width); + + // amount of rounding needed on corners varies by size of object + double scale = int.max(object_dimensions.width, object_dimensions.height); + draw_rounded_corners_filled(ctx, dimensions, origin, 0.25 * scale); + } + } + + protected virtual void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf, Gdk.Point origin) { + if (pixbuf.get_has_alpha()) { + ctx.rectangle(origin.x, origin.y, pixbuf.get_width(), pixbuf.get_height()); + ctx.fill(); + } + Gdk.cairo_set_source_pixbuf(ctx, pixbuf, origin.x, origin.y); + ctx.paint(); + } + + private int get_selection_border_width(int scale) { + return ((scale <= ((Thumbnail.MIN_SCALE + Thumbnail.MAX_SCALE) / 3)) ? 2 : 3) + + BORDER_WIDTH; + } + + protected virtual Gdk.Pixbuf? get_top_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_top_right_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_right_trinket(int scale) { + return null; + } + + public void paint(Cairo.Context ctx, Gdk.RGBA bg_color, Gdk.RGBA selected_color, + Gdk.RGBA text_color, Gdk.RGBA? border_color) { + // calc the top-left point of the pixbuf + Gdk.Point pixbuf_origin = Gdk.Point(); + pixbuf_origin.x = allocation.x + FRAME_WIDTH + BORDER_WIDTH; + pixbuf_origin.y = allocation.y + FRAME_WIDTH + BORDER_WIDTH; + + ctx.set_line_width(FRAME_WIDTH); + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + selected_color.alpha); + + // draw shadow + if (border_color != null) { + ctx.save(); + Dimensions shadow_dim = Dimensions(); + shadow_dim.width = pixbuf_dim.width + BORDER_WIDTH; + shadow_dim.height = pixbuf_dim.height + BORDER_WIDTH; + paint_shadow(ctx, shadow_dim, pixbuf_origin, SHADOW_RADIUS, SHADOW_INITIAL_ALPHA); + ctx.restore(); + } + + // draw selection border + if (is_selected()) { + // border thickness depends on the size of the thumbnail + ctx.save(); + paint_border(ctx, pixbuf_dim, pixbuf_origin, + get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); + ctx.restore(); + } + + // draw border + if (border_color != null) { + ctx.save(); + ctx.set_source_rgba(border_color.red, border_color.green, border_color.blue, + border_color.alpha); + paint_border(ctx, pixbuf_dim, pixbuf_origin, BORDER_WIDTH); + ctx.restore(); + } + + if (display_pixbuf != null) { + ctx.save(); + ctx.set_source_rgba(bg_color.red, bg_color.green, bg_color.blue, bg_color.alpha); + paint_image(ctx, display_pixbuf, pixbuf_origin); + ctx.restore(); + } + + ctx.set_source_rgba(text_color.red, text_color.green, text_color.blue, text_color.alpha); + + // title and subtitles are LABEL_PADDING below bottom of pixbuf + int text_y = allocation.y + FRAME_WIDTH + pixbuf_dim.height + FRAME_WIDTH + LABEL_PADDING; + if (title != null && title_visible) { + // get the layout sized so its with is no more than the pixbuf's + // resize the text width to be no more than the pixbuf's + title.allocation.x = allocation.x + FRAME_WIDTH; + title.allocation.y = text_y; + title.allocation.width = pixbuf_dim.width; + title.allocation.height = title.get_height(); + + ctx.move_to(title.allocation.x, title.allocation.y); + Pango.cairo_show_layout(ctx, title.get_pango_layout(pixbuf_dim.width)); + + text_y += title.get_height() + LABEL_PADDING; + } + + if (comment != null && comment_visible) { + comment.allocation.x = allocation.x + FRAME_WIDTH; + comment.allocation.y = text_y; + comment.allocation.width = pixbuf_dim.width; + comment.allocation.height = comment.get_height(); + + ctx.move_to(comment.allocation.x, comment.allocation.y); + Pango.cairo_show_layout(ctx, comment.get_pango_layout(pixbuf_dim.width)); + + text_y += comment.get_height() + LABEL_PADDING; + } + + if (subtitle != null && subtitle_visible) { + subtitle.allocation.x = allocation.x + FRAME_WIDTH; + subtitle.allocation.y = text_y; + subtitle.allocation.width = pixbuf_dim.width; + subtitle.allocation.height = subtitle.get_height(); + + ctx.move_to(subtitle.allocation.x, subtitle.allocation.y); + Pango.cairo_show_layout(ctx, subtitle.get_pango_layout(pixbuf_dim.width)); + + // increment text_y if more text lines follow + } + + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + selected_color.alpha); + + // draw trinkets last + Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); + int y = pixbuf_origin.y + pixbuf_dim.height - trinket.get_height() - + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_top_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); + int y = pixbuf_origin.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_top_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - + get_horizontal_trinket_offset() - TRINKET_PADDING; + int y = pixbuf_origin.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_bottom_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - + get_horizontal_trinket_offset() - TRINKET_PADDING; + int y = pixbuf_origin.y + pixbuf_dim.height - trinket.height - + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + } + + protected void set_horizontal_trinket_offset(int horizontal_trinket_offset) { + assert(horizontal_trinket_offset >= 0); + this.horizontal_trinket_offset = horizontal_trinket_offset; + } + + protected int get_horizontal_trinket_offset() { + return horizontal_trinket_offset; + } + + public void set_grid_coordinates(int col, int row) { + this.col = col; + this.row = row; + } + + public int get_column() { + return col; + } + + public int get_row() { + return row; + } + + public void brighten() { + // "should" implies "can" and "didn't already" + if (brightened != null || pixbuf == null) + return; + + // create a new lightened pixbuf to display + brightened = pixbuf.copy(); + shift_colors(brightened, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, 0); + + display_pixbuf = brightened; + + notify_view_altered(); + } + + public void unbrighten() { + // "should", "can", "didn't already" + if (brightened == null || pixbuf == null) + return; + + brightened = null; + + // return to the normal image + display_pixbuf = pixbuf; + + notify_view_altered(); + } + + public override void visibility_changed(bool visible) { + // if going from visible to hidden, unbrighten + if (!visible) + unbrighten(); + + base.visibility_changed(visible); + } + + private bool query_tooltip_on_text(CheckerboardItemText text, Gtk.Tooltip tooltip) { + if (!text.get_pango_layout().is_ellipsized()) + return false; + + if (text.is_marked_up()) + tooltip.set_markup(text.get_text()); + else + tooltip.set_text(text.get_text()); + + return true; + } + + public bool query_tooltip(int x, int y, Gtk.Tooltip tooltip) { + if (title != null && title_visible && coord_in_rectangle(x, y, title.allocation)) + return query_tooltip_on_text(title, tooltip); + + if (comment != null && comment_visible && coord_in_rectangle(x, y, comment.allocation)) + return query_tooltip_on_text(comment, tooltip); + + if (subtitle != null && subtitle_visible && coord_in_rectangle(x, y, subtitle.allocation)) + return query_tooltip_on_text(subtitle, tooltip); + + return false; + } +} + +public class CheckerboardLayout : Gtk.DrawingArea { + public const int TOP_PADDING = 16; + public const int BOTTOM_PADDING = 16; + public const int ROW_GUTTER_PADDING = 24; + + // the following are minimums, as the pads and gutters expand to fill up the window width + public const int COLUMN_GUTTER_PADDING = 24; + + // For a 40% alpha channel + private const double SELECTION_ALPHA = 0.40; + + // The number of pixels that the scrollbars of Gtk.ScrolledWindows allocate for themselves + // before their final size is computed. This must be taken into account when computing + // the width of this widget. This value was 0 in Gtk+ 2.x but is 1 in Gtk+ 3.x. See + // ticket #3870 (http://redmine.yorba.org/issues/3870) for more information + private const int SCROLLBAR_PLACEHOLDER_WIDTH = 1; + + private class LayoutRow { + public int y; + public int height; + public CheckerboardItem[] items; + + public LayoutRow(int y, int height, int num_in_row) { + this.y = y; + this.height = height; + this.items = new CheckerboardItem[num_in_row]; + } + } + + private ViewCollection view; + private string page_name = ""; + private LayoutRow[] item_rows = null; + private Gee.HashSet<CheckerboardItem> exposed_items = new Gee.HashSet<CheckerboardItem>(); + private Gtk.Adjustment hadjustment = null; + private Gtk.Adjustment vadjustment = null; + private string message = null; + private Gdk.RGBA selected_color; + private Gdk.RGBA unselected_color; + private Gdk.RGBA border_color; + private Gdk.RGBA bg_color; + private Gdk.Rectangle visible_page = Gdk.Rectangle(); + private int last_width = 0; + private int columns = 0; + private int rows = 0; + private Gdk.Point drag_origin = Gdk.Point(); + private Gdk.Point drag_endpoint = Gdk.Point(); + private Gdk.Rectangle selection_band = Gdk.Rectangle(); + private int scale = 0; + private bool flow_scheduled = false; + private bool exposure_dirty = true; + private CheckerboardItem? anchor = null; + private bool in_center_on_anchor = false; + private bool size_allocate_due_to_reflow = false; + private bool is_in_view = false; + private bool reflow_needed = false; + + public CheckerboardLayout(ViewCollection view) { + this.view = view; + + clear_drag_select(); + + // subscribe to the new collection + view.contents_altered.connect(on_contents_altered); + view.items_altered.connect(on_items_altered); + view.items_state_changed.connect(on_items_state_changed); + view.items_visibility_changed.connect(on_items_visibility_changed); + view.ordering_changed.connect(on_ordering_changed); + view.views_altered.connect(on_views_altered); + view.geometries_altered.connect(on_geometries_altered); + view.items_selected.connect(on_items_selection_changed); + view.items_unselected.connect(on_items_selection_changed); + + override_background_color(Gtk.StateFlags.NORMAL, Config.Facade.get_instance().get_bg_color()); + + Config.Facade.get_instance().colors_changed.connect(on_colors_changed); + + // CheckerboardItems offer tooltips + has_tooltip = true; + } + + ~CheckerboardLayout() { +#if TRACE_DTORS + debug("DTOR: CheckerboardLayout for %s", view.to_string()); +#endif + + view.contents_altered.disconnect(on_contents_altered); + view.items_altered.disconnect(on_items_altered); + view.items_state_changed.disconnect(on_items_state_changed); + view.items_visibility_changed.disconnect(on_items_visibility_changed); + view.ordering_changed.disconnect(on_ordering_changed); + view.views_altered.disconnect(on_views_altered); + view.geometries_altered.disconnect(on_geometries_altered); + view.items_selected.disconnect(on_items_selection_changed); + view.items_unselected.disconnect(on_items_selection_changed); + + if (hadjustment != null) + hadjustment.value_changed.disconnect(on_viewport_shifted); + + if (vadjustment != null) + vadjustment.value_changed.disconnect(on_viewport_shifted); + + if (parent != null) + parent.size_allocate.disconnect(on_viewport_resized); + + Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed); + } + + public void set_adjustments(Gtk.Adjustment hadjustment, Gtk.Adjustment vadjustment) { + this.hadjustment = hadjustment; + this.vadjustment = vadjustment; + + // monitor adjustment changes to report when the visible page shifts + hadjustment.value_changed.connect(on_viewport_shifted); + vadjustment.value_changed.connect(on_viewport_shifted); + + // monitor parent's size changes for a similar reason + parent.size_allocate.connect(on_viewport_resized); + } + + // This method allows for some optimizations to occur in reflow() by using the known max. + // width of all items in the layout. + public void set_scale(int scale) { + this.scale = scale; + } + + public int get_scale() { + return scale; + } + + public void set_name(string name) { + page_name = name; + } + + private void on_viewport_resized() { + Gtk.Requisition req; + get_preferred_size(null, out req); + + Gtk.Allocation parent_allocation; + parent.get_allocation(out parent_allocation); + + if (message == null) { + // set the layout's new size to be the same as the parent's width but maintain + // it's own height +#if TRACE_REFLOW + debug("on_viewport_resized: due_to_reflow=%s set_size_request %dx%d", + size_allocate_due_to_reflow.to_string(), parent_allocation.width, req.height); +#endif + set_size_request(parent_allocation.width - SCROLLBAR_PLACEHOLDER_WIDTH, req.height); + } else { + // set the layout's width and height to always match the parent's + set_size_request(parent_allocation.width, parent_allocation.height); + } + + // possible for this widget's size_allocate not to be called, so need to update the page + // rect here + viewport_resized(); + + if (!size_allocate_due_to_reflow) + clear_anchor(); + else + size_allocate_due_to_reflow = false; + } + + private void on_viewport_shifted() { + update_visible_page(); + need_exposure("on_viewport_shift"); + + clear_anchor(); + } + + private void on_items_selection_changed() { + clear_anchor(); + } + + private void clear_anchor() { + if (in_center_on_anchor) + return; + + anchor = null; + } + + private void update_anchor() { + assert(!in_center_on_anchor); + + Gee.List<CheckerboardItem> items_on_page = intersection(visible_page); + if (items_on_page.size == 0) { + anchor = null; + return; + } + + foreach (CheckerboardItem item in items_on_page) { + if (item.is_selected()) { + anchor = item; + return; + } + } + + if (vadjustment.get_value() == 0) { + anchor = null; + return; + } + + // this could be improved to always find the visual center...in the case where only + // a few photos are in the last visible row, this can choose a photo near the right + anchor = items_on_page.get((int) items_on_page.size / 2); + } + + private void center_on_anchor(double upper) { + if (anchor == null) + return; + + in_center_on_anchor = true; + + double anchor_pos = anchor.allocation.y + (anchor.allocation.height / 2) - + (vadjustment.get_page_size() / 2); + vadjustment.set_value(anchor_pos.clamp(vadjustment.get_lower(), + vadjustment.get_upper() - vadjustment.get_page_size())); + + in_center_on_anchor = false; + } + + private void on_contents_altered(Gee.Iterable<DataObject>? added, + Gee.Iterable<DataObject>? removed) { + if (added != null) + message = null; + + if (removed != null) { + foreach (DataObject object in removed) + exposed_items.remove((CheckerboardItem) object); + } + + // release spatial data structure ... contents_altered means a reflow is required, and since + // items may be removed, this ensures we're not holding the ref on a removed view + item_rows = null; + + need_reflow("on_contents_altered"); + } + + private void on_items_altered() { + need_reflow("on_items_altered"); + } + + private void on_items_state_changed(Gee.Iterable<DataView> changed) { + items_dirty("on_items_state_changed", changed); + } + + private void on_items_visibility_changed(Gee.Iterable<DataView> changed) { + need_reflow("on_items_visibility_changed"); + } + + private void on_ordering_changed() { + need_reflow("on_ordering_changed"); + } + + private void on_views_altered(Gee.Collection<DataView> altered) { + items_dirty("on_views_altered", altered); + } + + private void on_geometries_altered() { + need_reflow("on_geometries_altered"); + } + + private void need_reflow(string caller) { + if (flow_scheduled) + return; + + if (!is_in_view) { + reflow_needed = true; + return; + } + +#if TRACE_REFLOW + debug("need_reflow %s: %s", page_name, caller); +#endif + flow_scheduled = true; + Idle.add_full(Priority.HIGH, do_reflow); + } + + private bool do_reflow() { + reflow("do_reflow"); + need_exposure("do_reflow"); + + flow_scheduled = false; + + return false; + } + + private void need_exposure(string caller) { +#if TRACE_REFLOW + debug("need_exposure %s: %s", page_name, caller); +#endif + exposure_dirty = true; + queue_draw(); + } + + public void set_message(string? text) { + if (text == message) + return; + + message = text; + + if (text != null) { + // message is being set, change size to match parent's; if no parent, then the size + // will be set later when added to the parent + if (parent != null) { + Gtk.Allocation parent_allocation; + parent.get_allocation(out parent_allocation); + + set_size_request(parent_allocation.width, parent_allocation.height); + } + } else { + // message is being cleared, layout all the items again + need_reflow("set_message"); + } + } + + public void unset_message() { + set_message(null); + } + + private void update_visible_page() { + if (hadjustment != null && vadjustment != null) + visible_page = get_adjustment_page(hadjustment, vadjustment); + } + + public void set_in_view(bool in_view) { + is_in_view = in_view; + + if (in_view) { + if (reflow_needed) + need_reflow("set_in_view (true)"); + else + need_exposure("set_in_view (true)"); + } else + unexpose_items("set_in_view (false)"); + } + + public CheckerboardItem? get_item_at_pixel(double xd, double yd) { + if (message != null || item_rows == null) + return null; + + int x = (int) xd; + int y = (int) yd; + + // binary search the rows for the one in range of the pixel + LayoutRow in_range = null; + int min = 0; + int max = item_rows.length; + for(;;) { + int mid = min + ((max - min) / 2); + LayoutRow row = item_rows[mid]; + + if (row == null || y < row.y) { + // undershot + // row == null happens when there is an exact number of elements to fill the last row + max = mid - 1; + } else if (y > (row.y + row.height)) { + // undershot + min = mid + 1; + } else { + // bingo + in_range = row; + + break; + } + + if (min > max) + break; + } + + if (in_range == null) + return null; + + // look for item in row's column in range of the pixel + foreach (CheckerboardItem item in in_range.items) { + // this happens on an incompletely filled-in row (usually the last one with empty + // space remaining) + if (item == null) + continue; + + if (x < item.allocation.x) { + // overshot ... this happens because there's gaps in the columns + break; + } + + // need to verify actually over item's full dimensions, since they vary in size inside + // a row + if (x <= (item.allocation.x + item.allocation.width) && y >= item.allocation.y + && y <= (item.allocation.y + item.allocation.height)) + return item; + } + + return null; + } + + public Gee.List<CheckerboardItem> get_visible_items() { + return intersection(visible_page); + } + + public Gee.List<CheckerboardItem> intersection(Gdk.Rectangle area) { + Gee.ArrayList<CheckerboardItem> intersects = new Gee.ArrayList<CheckerboardItem>(); + + Gtk.Allocation allocation; + get_allocation(out allocation); + + Gdk.Rectangle bitbucket = Gdk.Rectangle(); + foreach (LayoutRow row in item_rows) { + if (row == null) + continue; + + if ((area.y + area.height) < row.y) { + // overshoot + break; + } + + if ((row.y + row.height) < area.y) { + // haven't reached it yet + continue; + } + + // see if the row intersects the area + Gdk.Rectangle row_rect = Gdk.Rectangle(); + row_rect.x = 0; + row_rect.y = row.y; + row_rect.width = allocation.width; + row_rect.height = row.height; + + if (area.intersect(row_rect, out bitbucket)) { + // see what elements, if any, intersect the area + foreach (CheckerboardItem item in row.items) { + if (item == null) + continue; + + if (area.intersect(item.allocation, out bitbucket)) + intersects.add(item); + } + } + } + + return intersects; + } + + public CheckerboardItem? get_item_relative_to(CheckerboardItem item, CompassPoint point) { + if (view.get_count() == 0) + return null; + + assert(columns > 0); + assert(rows > 0); + + int col = item.get_column(); + int row = item.get_row(); + + if (col < 0 || row < 0) { + critical("Attempting to locate item not placed in layout: %s", item.get_title()); + + return null; + } + + switch (point) { + case CompassPoint.NORTH: + if (--row < 0) + row = 0; + break; + + case CompassPoint.SOUTH: + if (++row >= rows) + row = rows - 1; + break; + + case CompassPoint.EAST: + if (++col >= columns) { + if(++row >= rows) { + row = rows - 1; + col = columns - 1; + } else { + col = 0; + } + } + break; + + case CompassPoint.WEST: + if (--col < 0) { + if (--row < 0) { + row = 0; + col = 0; + } else { + col = columns - 1; + } + } + break; + + default: + error("Bad compass point %d", (int) point); + } + + CheckerboardItem? new_item = get_item_at_coordinate(col, row); + + if (new_item == null && point == CompassPoint.SOUTH) { + // nothing directly below, get last item on next row + new_item = (CheckerboardItem?) view.get_last(); + if (new_item.get_row() <= item.get_row()) + new_item = null; + } + + return (new_item != null) ? new_item : item; + } + + public CheckerboardItem? get_item_at_coordinate(int col, int row) { + if (row >= item_rows.length) + return null; + + LayoutRow item_row = item_rows[row]; + if (item_row == null) + return null; + + if (col >= item_row.items.length) + return null; + + return item_row.items[col]; + } + + public void set_drag_select_origin(int x, int y) { + clear_drag_select(); + + Gtk.Allocation allocation; + get_allocation(out allocation); + + drag_origin.x = x.clamp(0, allocation.width); + drag_origin.y = y.clamp(0, allocation.height); + } + + public void set_drag_select_endpoint(int x, int y) { + Gtk.Allocation allocation; + get_allocation(out allocation); + + drag_endpoint.x = x.clamp(0, allocation.width); + drag_endpoint.y = y.clamp(0, allocation.height); + + // drag_origin and drag_endpoint are maintained only to generate selection_band; all reporting + // and drawing functions refer to it, not drag_origin and drag_endpoint + Gdk.Rectangle old_selection_band = selection_band; + selection_band = Box.from_points(drag_origin, drag_endpoint).get_rectangle(); + + // force repaint of the union of the old and new, which covers the band reducing in size + if (get_window() != null) { + Gdk.Rectangle union; + selection_band.union(old_selection_band, out union); + + queue_draw_area(union.x, union.y, union.width, union.height); + } + } + + public Gee.List<CheckerboardItem>? items_in_selection_band() { + if (!Dimensions.for_rectangle(selection_band).has_area()) + return null; + + return intersection(selection_band); + } + + public bool is_drag_select_active() { + return drag_origin.x >= 0 && drag_origin.y >= 0; + } + + public void clear_drag_select() { + selection_band = Gdk.Rectangle(); + drag_origin.x = -1; + drag_origin.y = -1; + drag_endpoint.x = -1; + drag_endpoint.y = -1; + + // force a total repaint to clear the selection band + queue_draw(); + } + + private void viewport_resized() { + // update visible page rect + update_visible_page(); + + // only reflow() if the width has changed + if (visible_page.width != last_width) { + int old_width = last_width; + last_width = visible_page.width; + + need_reflow("viewport_resized (%d -> %d)".printf(old_width, visible_page.width)); + } else { + // don't need to reflow but exposure may have changed + need_exposure("viewport_resized (same width=%d)".printf(last_width)); + } + } + + private void expose_items(string caller) { + // create a new hash set of exposed items that represents an intersection of the old set + // and the new + Gee.HashSet<CheckerboardItem> new_exposed_items = new Gee.HashSet<CheckerboardItem>(); + + view.freeze_notifications(); + + Gee.List<CheckerboardItem> items = get_visible_items(); + foreach (CheckerboardItem item in items) { + new_exposed_items.add(item); + + // if not in the old list, then need to expose + if (!exposed_items.remove(item)) + item.exposed(); + } + + // everything remaining in the old exposed list is now unexposed + foreach (CheckerboardItem item in exposed_items) + item.unexposed(); + + // swap out lists + exposed_items = new_exposed_items; + exposure_dirty = false; + +#if TRACE_REFLOW + debug("expose_items %s: exposed %d items, thawing", page_name, exposed_items.size); +#endif + view.thaw_notifications(); +#if TRACE_REFLOW + debug("expose_items %s: thaw finished", page_name); +#endif + } + + private void unexpose_items(string caller) { + view.freeze_notifications(); + + foreach (CheckerboardItem item in exposed_items) + item.unexposed(); + + exposed_items.clear(); + exposure_dirty = false; + +#if TRACE_REFLOW + debug("unexpose_items %s: thawing", page_name); +#endif + view.thaw_notifications(); +#if TRACE_REFLOW + debug("unexpose_items %s: thawed", page_name); +#endif + } + + private void reflow(string caller) { + reflow_needed = false; + + // if set in message mode, nothing to do here + if (message != null) + return; + + Gtk.Allocation allocation; + get_allocation(out allocation); + + int visible_width = (visible_page.width > 0) ? visible_page.width : allocation.width; + +#if TRACE_REFLOW + debug("reflow: Using visible page width of %d (allocated: %d)", visible_width, + allocation.width); +#endif + + // don't bother until layout is of some appreciable size (even this is too low) + if (visible_width <= 1) + return; + + int total_items = view.get_count(); + + // need to set_size in case all items were removed and the viewport size has changed + if (total_items == 0) { + set_size_request(visible_width, 0); + item_rows = new LayoutRow[0]; + + return; + } + +#if TRACE_REFLOW + debug("reflow %s: %s (%d items)", page_name, caller, total_items); +#endif + + // look for anchor if there is none currently + if (anchor == null || !anchor.is_visible()) + update_anchor(); + + // clear the rows data structure, as the reflow will completely rearrange it + item_rows = null; + + // Step 1: Determine the widest row in the layout, and from it the number of columns. + // If owner supplies an image scaling for all items in the layout, then this can be + // calculated quickly. + int max_cols = 0; + if (scale > 0) { + // calculate interior width + int remaining_width = visible_width - (COLUMN_GUTTER_PADDING * 2); + int max_item_width = CheckerboardItem.get_max_width(scale); + max_cols = remaining_width / max_item_width; + if (max_cols <= 0) + max_cols = 1; + + // if too large with gutters, decrease until columns fit + while (max_cols > 1 + && ((max_cols * max_item_width) + ((max_cols - 1) * COLUMN_GUTTER_PADDING) > remaining_width)) { +#if TRACE_REFLOW + debug("reflow %s: scaled cols estimate: reducing max_cols from %d to %d", page_name, + max_cols, max_cols - 1); +#endif + max_cols--; + } + + // special case: if fewer items than columns, they are the columns + if (total_items < max_cols) + max_cols = total_items; + +#if TRACE_REFLOW + debug("reflow %s: scaled cols estimate: max_cols=%d remaining_width=%d max_item_width=%d", + page_name, max_cols, remaining_width, max_item_width); +#endif + } else { + int x = COLUMN_GUTTER_PADDING; + int col = 0; + int row_width = 0; + int widest_row = 0; + + for (int ctr = 0; ctr < total_items; ctr++) { + CheckerboardItem item = (CheckerboardItem) view.get_at(ctr); + Dimensions req = item.requisition; + + // the items must be requisitioned for this code to work + assert(req.has_area()); + + // carriage return (i.e. this item will overflow the view) + if ((x + req.width + COLUMN_GUTTER_PADDING) > visible_width) { + if (row_width > widest_row) { + widest_row = row_width; + max_cols = col; + } + + col = 0; + x = COLUMN_GUTTER_PADDING; + row_width = 0; + } + + x += req.width + COLUMN_GUTTER_PADDING; + row_width += req.width; + + col++; + } + + // account for dangling last row + if (row_width > widest_row) + max_cols = col; + +#if TRACE_REFLOW + debug("reflow %s: manual cols estimate: max_cols=%d widest_row=%d", page_name, max_cols, + widest_row); +#endif + } + + assert(max_cols > 0); + int max_rows = (total_items / max_cols) + 1; + + // Step 2: Now that the number of columns is known, find the maximum height for each row + // and the maximum width for each column + int row = 0; + int tallest = 0; + int widest = 0; + int row_alignment_point = 0; + int total_width = 0; + int col = 0; + int[] column_widths = new int[max_cols]; + int[] row_heights = new int[max_rows]; + int[] alignment_points = new int[max_rows]; + int gutter = 0; + + for (;;) { + for (int ctr = 0; ctr < total_items; ctr++ ) { + CheckerboardItem item = (CheckerboardItem) view.get_at(ctr); + Dimensions req = item.requisition; + int alignment_point = item.get_alignment_point(); + + // alignment point better be sane + assert(alignment_point < req.height); + + if (req.height > tallest) + tallest = req.height; + + if (req.width > widest) + widest = req.width; + + if (alignment_point > row_alignment_point) + row_alignment_point = alignment_point; + + // store largest thumb size of each column as well as track the total width of the + // layout (which is the sum of the width of each column) + if (column_widths[col] < req.width) { + total_width -= column_widths[col]; + column_widths[col] = req.width; + total_width += req.width; + } + + if (++col >= max_cols) { + alignment_points[row] = row_alignment_point; + row_heights[row++] = tallest; + + col = 0; + row_alignment_point = 0; + tallest = 0; + } + } + + // account for final dangling row + if (col != 0) { + alignment_points[row] = row_alignment_point; + row_heights[row] = tallest; + } + + // Step 3: Calculate the gutter between the items as being equidistant of the + // remaining space (adding one gutter to account for the right-hand one) + gutter = (visible_width - total_width) / (max_cols + 1); + + // if only one column, gutter size could be less than minimums + if (max_cols == 1) + break; + + // have to reassemble if the gutter is too small ... this happens because Step One + // takes a guess at the best column count, but when the max. widths of the columns are + // added up, they could overflow + if (gutter < COLUMN_GUTTER_PADDING) { + max_cols--; + max_rows = (total_items / max_cols) + 1; + +#if TRACE_REFLOW + debug("reflow %s: readjusting columns: alloc.width=%d total_width=%d widest=%d gutter=%d max_cols now=%d", + page_name, visible_width, total_width, widest, gutter, max_cols); +#endif + + col = 0; + row = 0; + tallest = 0; + widest = 0; + total_width = 0; + row_alignment_point = 0; + column_widths = new int[max_cols]; + row_heights = new int[max_rows]; + alignment_points = new int[max_rows]; + } else { + break; + } + } + +#if TRACE_REFLOW + debug("reflow %s: width:%d total_width:%d max_cols:%d gutter:%d", page_name, visible_width, + total_width, max_cols, gutter); +#endif + + // Step 4: Recalculate the height of each row according to the row's alignment point (which + // may cause shorter items to extend below the bottom of the tallest one, extending the + // height of the row) + col = 0; + row = 0; + + for (int ctr = 0; ctr < total_items; ctr++) { + CheckerboardItem item = (CheckerboardItem) view.get_at(ctr); + Dimensions req = item.requisition; + + // this determines how much padding the item requires to be bottom-alignment along the + // alignment point; add to the height and you have the item's "true" height on the + // laid-down row + int true_height = req.height + (alignment_points[row] - item.get_alignment_point()); + assert(true_height >= req.height); + + // add that to its height to determine it's actual height on the laid-down row + if (true_height > row_heights[row]) { +#if TRACE_REFLOW + debug("reflow %s: Adjusting height of row %d from %d to %d", page_name, row, + row_heights[row], true_height); +#endif + row_heights[row] = true_height; + } + + // carriage return + if (++col >= max_cols) { + col = 0; + row++; + } + } + + // for the spatial structure + item_rows = new LayoutRow[max_rows]; + + // Step 5: Lay out the items in the space using all the information gathered + int x = gutter; + int y = TOP_PADDING; + col = 0; + row = 0; + LayoutRow current_row = null; + + for (int ctr = 0; ctr < total_items; ctr++) { + CheckerboardItem item = (CheckerboardItem) view.get_at(ctr); + Dimensions req = item.requisition; + + // this centers the item in the column + int xpadding = (column_widths[col] - req.width) / 2; + assert(xpadding >= 0); + + // this bottom-aligns the item along the discovered alignment point + int ypadding = alignment_points[row] - item.get_alignment_point(); + assert(ypadding >= 0); + + // save pixel and grid coordinates + item.allocation.x = x + xpadding; + item.allocation.y = y + ypadding; + item.allocation.width = req.width; + item.allocation.height = req.height; + item.set_grid_coordinates(col, row); + + // add to current row in spatial data structure + if (current_row == null) + current_row = new LayoutRow(y, row_heights[row], max_cols); + + current_row.items[col] = item; + + x += column_widths[col] + gutter; + + // carriage return + if (++col >= max_cols) { + assert(current_row != null); + item_rows[row] = current_row; + current_row = null; + + x = gutter; + y += row_heights[row] + ROW_GUTTER_PADDING; + col = 0; + row++; + } + } + + // add last row to spatial data structure + if (current_row != null) + item_rows[row] = current_row; + + // save dimensions of checkerboard + columns = max_cols; + rows = row + 1; + assert(rows == max_rows); + + // Step 6: Define the total size of the page as the size of the visible width (to avoid + // the horizontal scrollbar from appearing) and the height of all the items plus padding + int total_height = y + row_heights[row] + BOTTOM_PADDING; + if (visible_width != allocation.width || total_height != allocation.height) { +#if TRACE_REFLOW + debug("reflow %s: Changing layout dimensions from %dx%d to %dx%d", page_name, + allocation.width, allocation.height, visible_width, total_height); +#endif + set_size_request(visible_width, total_height); + size_allocate_due_to_reflow = true; + + // when height changes, center on the anchor to minimize amount of visual change + center_on_anchor(total_height); + } + } + + private void items_dirty(string reason, Gee.Iterable<DataView> items) { + Gdk.Rectangle dirty = Gdk.Rectangle(); + foreach (DataView data_view in items) { + CheckerboardItem item = (CheckerboardItem) data_view; + + if (!item.is_visible()) + continue; + + assert(view.contains(item)); + + // if not allocated, need to reflow the entire layout; don't bother queueing a draw + // for any of these, reflow will handle that + if (item.allocation.width <= 0 || item.allocation.height <= 0) { + need_reflow("items_dirty: %s".printf(reason)); + + return; + } + + // only mark area as dirty if visible in viewport + Gdk.Rectangle intersection = Gdk.Rectangle(); + if (!visible_page.intersect(item.allocation, out intersection)) + continue; + + // grow the dirty area + if (dirty.width == 0 || dirty.height == 0) + dirty = intersection; + else + dirty.union(intersection, out dirty); + } + + if (dirty.width > 0 && dirty.height > 0) { +#if TRACE_REFLOW + debug("items_dirty %s (%s): Queuing draw of dirty area %s on visible_page %s", + page_name, reason, rectangle_to_string(dirty), rectangle_to_string(visible_page)); +#endif + queue_draw_area(dirty.x, dirty.y, dirty.width, dirty.height); + } + } + + public override void map() { + base.map(); + + set_colors(); + } + + private void set_colors(bool in_focus = true) { + // set up selected/unselected colors + selected_color = Config.Facade.get_instance().get_selected_color(in_focus); + unselected_color = Config.Facade.get_instance().get_unselected_color(); + border_color = Config.Facade.get_instance().get_border_color(); + bg_color = get_style_context().get_background_color(Gtk.StateFlags.NORMAL); + } + + public override void size_allocate(Gtk.Allocation allocation) { + base.size_allocate(allocation); + + viewport_resized(); + } + + public override bool draw(Cairo.Context ctx) { + // Note: It's possible for draw to be called when in_view is false; this happens + // when pages are switched prior to switched_to() being called, and some of the other + // controls allow for events to be processed while they are orienting themselves. Since + // we want switched_to() to be the final call in the process (indicating that the page is + // now in place and should do its thing to update itself), have to be be prepared for + // GTK/GDK calls between the widgets being actually present on the screen and "switched to" + + // watch for message mode + if (message == null) { +#if TRACE_REFLOW + debug("draw %s: %s", page_name, rectangle_to_string(visible_page)); +#endif + + if (exposure_dirty) + expose_items("draw"); + + // have all items in the exposed area paint themselves + foreach (CheckerboardItem item in intersection(visible_page)) { + item.paint(ctx, bg_color, item.is_selected() ? selected_color : unselected_color, + unselected_color, border_color); + } + } else { + // draw the message in the center of the window + Pango.Layout pango_layout = create_pango_layout(message); + int text_width, text_height; + pango_layout.get_pixel_size(out text_width, out text_height); + + Gtk.Allocation allocation; + get_allocation(out allocation); + + int x = allocation.width - text_width; + x = (x > 0) ? x / 2 : 0; + + int y = allocation.height - text_height; + y = (y > 0) ? y / 2 : 0; + + ctx.set_source_rgb(unselected_color.red, unselected_color.green, unselected_color.blue); + ctx.move_to(x, y); + Pango.cairo_show_layout(ctx, pango_layout); + } + + bool result = (base.draw != null) ? base.draw(ctx) : true; + + // draw the selection band last, so it appears floating over everything else + draw_selection_band(ctx); + + return result; + } + + private void draw_selection_band(Cairo.Context ctx) { + // no selection band, nothing to draw + if (selection_band.width <= 1 || selection_band.height <= 1) + return; + + // This requires adjustments + if (hadjustment == null || vadjustment == null) + return; + + // find the visible intersection of the viewport and the selection band + Gdk.Rectangle visible_page = get_adjustment_page(hadjustment, vadjustment); + Gdk.Rectangle visible_band = Gdk.Rectangle(); + visible_page.intersect(selection_band, out visible_band); + + // pixelate selection rectangle interior + if (visible_band.width > 1 && visible_band.height > 1) { + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + SELECTION_ALPHA); + ctx.rectangle(visible_band.x, visible_band.y, visible_band.width, + visible_band.height); + ctx.fill(); + } + + // border + // See this for an explanation of the adjustments to the band's dimensions + // http://cairographics.org/FAQ/#sharp_lines + ctx.set_line_width(1.0); + ctx.set_line_cap(Cairo.LineCap.SQUARE); + ctx.set_source_rgb(selected_color.red, selected_color.green, selected_color.blue); + ctx.rectangle((double) selection_band.x + 0.5, (double) selection_band.y + 0.5, + (double) selection_band.width - 1.0, (double) selection_band.height - 1.0); + ctx.stroke(); + } + + public override bool query_tooltip(int x, int y, bool keyboard_mode, Gtk.Tooltip tooltip) { + CheckerboardItem? item = get_item_at_pixel(x, y); + + return (item != null) ? item.query_tooltip(x, y, tooltip) : false; + } + + private void on_colors_changed() { + override_background_color(Gtk.StateFlags.NORMAL, Config.Facade.get_instance().get_bg_color()); + set_colors(); + } + + public override bool focus_in_event(Gdk.EventFocus event) { + set_colors(true); + items_dirty("focus_in_event", view.get_selected()); + + return base.focus_in_event(event); + } + + public override bool focus_out_event(Gdk.EventFocus event) { + set_colors(false); + items_dirty("focus_out_event", view.get_selected()); + + return base.focus_out_event(event); + } +} |