// -*- Mode: js; indent-tabs-mode: nil; c-basic-offset: 4; tab-width: 4 -*-
//
// Copyright (C) 2014-2015 Daiki Ueno <dueno@src.gnome.org>
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
const Lang = imports.lang;
const Params = imports.params;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const Gdk = imports.gi.Gdk;
const Cairo = imports.cairo;
const Pango = imports.gi.Pango;
const PangoCairo = imports.gi.PangoCairo;
const Gc = imports.gi.Gc;
const Main = imports.main;
const Util = imports.util;
const BASELINE_OFFSET = 0.85;
const CELLS_PER_ROW = 5;
const NUM_ROWS = 5;
const NUM_COLUMNS = 5;
const CELL_SIZE = 50;
function getCellSize(fontDescription) {
if (fontDescription == null
|| fontDescription.get_size() == 0)
return CELL_SIZE;
return fontDescription.get_size() * 2 / Pango.SCALE;
}
const CharacterListRow = new Lang.Class({
Name: 'CharacterListRow',
Extends: GObject.Object,
_init: function(params) {
let filtered = Params.filter(params, { characters: null,
fontDescription: null,
overlayFontDescription: null });
params = Params.fill(params, {});
this.parent(params);
this._characters = filtered.characters;
this._fontDescription = filtered.fontDescription;
this._overlayFontDescription = filtered.overlayFontDescription;
},
draw: function(cr, x, y, width, height) {
let layout = PangoCairo.create_layout(cr);
layout.set_font_description(this._fontDescription);
// Draw baseline.
// FIXME: Pick the baseline color from CSS.
cr.setSourceRGBA(114.0 / 255.0, 159.0 / 255.0, 207.0 / 255.0, 1.0);
cr.setLineWidth(0.5);
cr.moveTo(x, y + BASELINE_OFFSET * height);
cr.relLineTo(width, 0);
cr.stroke();
cr.setSourceRGBA(0.0, 0.0, 0.0, 1.0);
// Draw characters. Do centering and attach to the baseline.
let cellSize = getCellSize(this._fontDescription);
for (let i in this._characters) {
var cellRect = new Gdk.Rectangle({ x: x + cellSize * i,
y: y,
width: cellSize,
height: cellSize });
if (Gc.character_is_invisible(this._characters[i])) {
this._drawBoundingBox(cr, cellRect, this._characters[i]);
this._drawCharacterName(cr, cellRect, this._characters[i]);
} else {
layout.set_text(this._characters[i], -1);
if (layout.get_unknown_glyphs_count () == 0) {
let layoutBaseline = layout.get_baseline();
let [logicalRect, inkRect] = layout.get_extents();
cr.moveTo(x + cellSize * i - logicalRect.x / Pango.SCALE +
(cellSize - logicalRect.width / Pango.SCALE) / 2,
y + BASELINE_OFFSET * height -
layoutBaseline / Pango.SCALE);
PangoCairo.show_layout(cr, layout);
} else {
this._drawBoundingBox(cr, cellRect, this._characters[i]);
this._drawCharacterName(cr, cellRect, this._characters[i]);
}
}
}
},
_computeBoundingBox: function(cr, cellRect, uc) {
let layout = PangoCairo.create_layout(cr);
layout.set_font_description(this._fontDescription);
layout.set_text(uc, -1);
let shapeRect;
let layoutBaseline;
if (layout.get_unknown_glyphs_count() == 0) {
let [logicalRect, inkRect] = layout.get_extents();
layoutBaseline = layout.get_baseline();
shapeRect = inkRect;
} else {
// If the character cannot be rendered with the current
// font settings, show a rectangle calculated from the
// base glyph ('A').
if (this._baseGlyphRect == null) {
layout.set_text('A', -1);
let [baseLogicalRect, baseInkRect] = layout.get_extents();
this._baseGlyphLayoutBaseline = layout.get_baseline();
this._baseGlyphRect = baseInkRect;
}
layoutBaseline = this._baseGlyphLayoutBaseline;
shapeRect = new Pango.Rectangle({
x: this._baseGlyphRect.x,
y: this._baseGlyphRect.y,
width: this._baseGlyphRect.width,
height: this._baseGlyphRect.height
});
let characterWidth = Gc.character_width (uc);
if (characterWidth > 1)
shapeRect.width *= characterWidth;
}
shapeRect.x = cellRect.x - shapeRect.x / Pango.SCALE +
(cellRect.width - shapeRect.width / Pango.SCALE) / 2;
shapeRect.y = cellRect.y + BASELINE_OFFSET * cellRect.height -
layoutBaseline / Pango.SCALE;
shapeRect.width = shapeRect.width / Pango.SCALE;
shapeRect.height = shapeRect.height / Pango.SCALE;
return shapeRect;
},
_drawBoundingBox: function(cr, cellRect, uc) {
cr.save();
cr.rectangle(cellRect.x, cellRect.y, cellRect.width, cellRect.height);
cr.clip();
let layout = PangoCairo.create_layout(cr);
layout.set_font_description(this._fontDescription);
layout.set_text(uc, -1);
let shapeRect = this._computeBoundingBox(cr, cellRect, uc);
let borderWidth = 1;
cr.rectangle(shapeRect.x - borderWidth * 2,
shapeRect.y - borderWidth * 2,
shapeRect.width + borderWidth * 2,
shapeRect.height + borderWidth * 2);
cr.setSourceRGBA(239.0 / 255.0, 239.0 / 255.0, 239.0 / 255.0, 1.0);
cr.fill();
cr.restore();
},
_drawCharacterName: function(cr, cellRect, uc) {
cr.save();
cr.rectangle(cellRect.x, cellRect.y, cellRect.width, cellRect.height);
cr.clip();
let layout = PangoCairo.create_layout(cr);
layout.set_width(cellRect.width * Pango.SCALE * 0.8);
layout.set_height(cellRect.height * Pango.SCALE * 0.8);
layout.set_wrap(Pango.WrapMode.WORD);
layout.set_ellipsize(Pango.EllipsizeMode.END);
layout.set_alignment(Pango.Alignment.CENTER);
layout.set_font_description(this._overlayFontDescription);
let name = Gc.character_name(uc);
let text = name == null ? _('Unassigned') : Util.capitalize(name);
layout.set_text(text, -1);
let [logicalRect, inkRect] = layout.get_extents();
cr.moveTo(cellRect.x - logicalRect.x / Pango.SCALE +
(cellRect.width - logicalRect.width / Pango.SCALE) / 2,
cellRect.y - logicalRect.y / Pango.SCALE +
(cellRect.height - logicalRect.height / Pango.SCALE) / 2);
cr.setSourceRGBA(0.0, 0.0, 0.0, 1.0);
PangoCairo.show_layout(cr, layout);
cr.restore();
}
});
const CharacterListWidget = new Lang.Class({
Name: 'CharacterListWidget',
Extends: Gtk.DrawingArea,
Signals: {
'character-selected': { param_types: [ GObject.TYPE_STRING ] }
},
_init: function(params) {
let filtered = Params.filter(params, {
fontDescription: null,
numRows: NUM_ROWS
});
params = Params.fill(params, {});
this.parent(params);
let context = this.get_style_context();
context.add_class('character-list');
context.save();
this._cellsPerRow = CELLS_PER_ROW;
this._fontDescription = filtered.fontDescription;
this._numRows = filtered.numRows;
this._characters = [];
this._rows = [];
this.add_events(Gdk.EventMask.BUTTON_PRESS_MASK |
Gdk.EventMask.BUTTON_RELEASE_MASK);
this._character = null;
this.drag_source_set(Gdk.ModifierType.BUTTON1_MASK,
null,
Gdk.DragAction.COPY);
this.drag_source_add_text_targets();
},
vfunc_drag_begin: function(context) {
let cellSize = getCellSize(this._fontDescription);
this._dragSurface = new Cairo.ImageSurface(Cairo.Format.ARGB32,
cellSize,
cellSize);
let cr = new Cairo.Context(this._dragSurface);
cr.setSourceRGBA(1.0, 1.0, 1.0, 1.0);
cr.paint();
cr.setSourceRGBA(0.0, 0.0, 0.0, 1.0);
let row = this._createCharacterListRow([this._character]);
row.draw(cr, 0, 0, cellSize, cellSize);
Gtk.drag_set_icon_surface(context, this._dragSurface, 0, 0);
},
vfunc_drag_data_get: function(context, data, info, time) {
if (this._character != null)
data.set_text(this._character, -1);
},
vfunc_button_press_event: function(event) {
let allocation = this.get_allocation();
let cellSize = getCellSize(this._fontDescription);
let x = Math.floor(event.x / cellSize);
let y = Math.floor(event.y / cellSize);
let index = y * this._cellsPerRow + x;
if (index < this._characters.length)
this._character = this._characters[index];
else
this._character = null;
return false;
},
vfunc_button_release_event: function(event) {
if (this._character)
this.emit('character-selected', this._character);
return false;
},
vfunc_get_request_mode: function() {
return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH;
},
vfunc_get_preferred_height: function() {
let [minWidth, natWidth] = this.vfunc_get_preferred_width();
return this.vfunc_get_preferred_height_for_width(minWidth);
},
vfunc_get_preferred_height_for_width: function(width) {
let height = Math.max(this._rows.length, this._numRows) *
getCellSize(this._fontDescription);
return [height, height];
},
vfunc_get_preferred_width: function() {
return this.vfunc_get_preferred_width_for_height(0);
},
vfunc_get_preferred_width_for_height: function(height) {
let cellSize = getCellSize(this._fontDescription);
let minWidth = NUM_COLUMNS * cellSize;
let natWidth = Math.max(this._cellsPerRow, NUM_COLUMNS) * cellSize;
return [minWidth, natWidth];
},
vfunc_size_allocate: function(allocation) {
this.parent(allocation);
let cellSize = getCellSize(this._fontDescription);
let cellsPerRow = Math.floor(allocation.width / cellSize);
if (cellsPerRow != this._cellsPerRow) {
// Reflow if the number of cells per row has changed.
this._cellsPerRow = cellsPerRow;
this.setCharacters(this._characters);
}
},
_createCharacterListRow: function(characters) {
var context = this.get_pango_context();
var fontDescription = context.get_font_description();
fontDescription.set_size(fontDescription.get_size() * 0.8);
let row = new CharacterListRow({
characters: characters,
fontDescription: this._fontDescription,
overlayFontDescription: fontDescription
});
return row;
},
setFontDescription: function(fontDescription) {
this._fontDescription = fontDescription;
},
setCharacters: function(characters) {
this._rows = [];
this._characters = characters;
let start = 0, stop = 1;
for (; stop <= characters.length; stop++) {
if (stop % this._cellsPerRow == 0) {
let rowCharacters = characters.slice(start, stop);
let row = this._createCharacterListRow(rowCharacters);
this._rows.push(row);
start = stop;
}
}
if (start != stop - 1) {
let rowCharacters = characters.slice(start, stop);
let row = this._createCharacterListRow(rowCharacters);
this._rows.push(row);
}
this.queue_resize();
this.queue_draw();
},
vfunc_draw: function(cr) {
// Clear the canvas.
let context = this.get_style_context();
let fg = context.get_color(Gtk.StateFlags.NORMAL);
let bg = context.get_background_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
cr.paint();
cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha);
// Use device coordinates directly, since PangoCairo doesn't
// work well with scaled matrix:
// https://bugzilla.gnome.org/show_bug.cgi?id=700592
let allocation = this.get_allocation();
// Redraw rows within the clipped region.
let [x1, y1, x2, y2] = cr.clipExtents();
let cellSize = getCellSize(this._fontDescription);
let start = Math.max(0, Math.floor(y1 / cellSize));
let end = Math.min(this._rows.length, Math.ceil(y2 / cellSize));
for (let index = start; index < end; index++) {
this._rows[index].draw(cr, 0, index * cellSize,
allocation.width, cellSize);
}
}
});
const MAX_SEARCH_RESULTS = 100;
var FontFilter = new Lang.Class({
Name: 'FontFilter',
Extends: GObject.Object,
Properties: {
'font': GObject.ParamSpec.string(
'font', '', '',
GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
'Cantarell 50')
},
Signals: {
'filter-set': { param_types: [] }
},
get font() {
return this._font;
},
set font(v) {
let fontDescription = Pango.FontDescription.from_string(v);
if (fontDescription.get_size() == 0)
fontDescription.set_size(CELL_SIZE * Pango.SCALE);
if (this._fontDescription &&
fontDescription.equal(this._fontDescription))
return;
this._font = v;
this._fontDescription = fontDescription;
},
get fontDescription() {
if (this._filterFontDescription)
return this._filterFontDescription;
return this._fontDescription;
},
_init: function(params) {
params = Params.fill(params, {});
this.parent(params);
this._fontDescription = null;
this._filterFontDescription = null;
Main.settings.bind('font', this, 'font', Gio.SettingsBindFlags.DEFAULT);
},
setFilterFont: function(v) {
let fontDescription;
if (v == null) {
fontDescription = null;
} else {
fontDescription = Pango.FontDescription.from_string(v);
fontDescription.set_size(this._fontDescription.get_size());
}
if ((this._filterFontDescription != null && fontDescription == null) ||
(this._filterFontDescription == null && fontDescription != null) ||
(this._filterFontDescription != null && fontDescription != null &&
!fontDescription.equal(this._filterFontDescription))) {
this._filterFontDescription = fontDescription;
this.emit('filter-set');
}
},
apply: function(widget, characters) {
let fontDescription = this._fontDescription;
if (this._filterFontDescription) {
let context = widget.get_pango_context();
let filterFont = context.load_font(this._filterFontDescription);
let filteredCharacters = [];
for (let index = 0; index < characters.length; index++) {
let uc = characters[index];
if (Gc.pango_context_font_has_glyph(context, filterFont, uc))
filteredCharacters.push(uc);
}
characters = filteredCharacters;
fontDescription = this._filterFontDescription;
}
return [fontDescription, characters];
},
});
var CharacterListView = new Lang.Class({
Name: 'CharacterListView',
Extends: Gtk.Stack,
Template: 'resource:///org/gnome/Characters/characterlist.ui',
InternalChildren: ['loading-spinner'],
Signals: {
'character-selected': { param_types: [ GObject.TYPE_STRING ] }
},
_init: function(params) {
let filtered = Params.filter(params, {
fontFilter: null
});
params = Params.fill(params, {
hexpand: true, vexpand: true,
transition_type: Gtk.StackTransitionType.CROSSFADE
});
this.parent(params);
this._fontFilter = filtered.fontFilter;
this._characterList = new CharacterListWidget({
hexpand: true,
vexpand: true,
fontDescription: this._fontFilter.fontDescription
});
this._characterList.connect('character-selected',
Lang.bind(this, function(w, c) {
this.emit('character-selected', c);
}));
let scroll = new Gtk.ScrolledWindow({
hscrollbar_policy: Gtk.PolicyType.NEVER,
visible: true
});
scroll.add(this._characterList);
let context = scroll.get_style_context();
context.add_class('character-list-scroll');
context.save();
this.add_named(scroll, 'character-list');
this.visible_child_name = 'character-list';
this._fontFilter.connect('filter-set',
Lang.bind(this, this._updateCharacterList));
this._characters = [];
this._spinnerTimeoutId = 0;
this._searchContext = null;
this._cancellable = new Gio.Cancellable();
this._cancellable.connect(Lang.bind(this, function () {
this._stopSpinner();
this._searchContext = null;
this._characters = [];
this._updateCharacterList();
}));
scroll.connect('edge-reached', Lang.bind(this, this._onEdgeReached));
scroll.connect('size-allocate', Lang.bind(this, this._onSizeAllocate));
},
_startSpinner: function() {
this._stopSpinner();
this._spinnerTimeoutId =
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000,
Lang.bind(this, function () {
this._loading_spinner.start();
this.visible_child_name = 'loading';
this.show_all();
}));
},
_stopSpinner: function() {
if (this._spinnerTimeoutId > 0) {
GLib.source_remove(this._spinnerTimeoutId);
this._spinnerTimeoutId = 0;
this._loading_spinner.stop();
}
},
_finishSearch: function(result) {
this._stopSpinner();
let characters = Util.searchResultToArray(result);
this.setCharacters(characters);
},
setCharacters: function(characters) {
this._characters = characters;
this._updateCharacterList();
},
_updateCharacterList: function() {
let [fontDescription, characters] = this._fontFilter.apply(this, this._characters);
this._characterList.setFontDescription(fontDescription);
this._characterList.setCharacters(characters);
if (characters.length == 0) {
this.visible_child_name = 'unavailable';
} else {
this.visible_child_name = 'character-list';
}
this.show_all();
},
_maybeLoadMore() {
if (this._searchContext != null && !this._searchContext.is_finished()) {
this._searchWithContext(this._searchContext, MAX_SEARCH_RESULTS);
}
},
_onEdgeReached: function(scrolled, pos) {
if (pos == Gtk.PositionType.BOTTOM) {
this._maybeLoadMore();
}
},
get initialSearchCount() {
// Use our parents allocation; we aren't visible before we do the
// initial search, so our allocation is 1x1
let allocation = this.get_parent().get_allocation();
// Sometimes more MAX_SEARCH_RESULTS are visible on screen
// (eg. fullscreen at 1080p). We always present a over-full screen,
// otherwise the lazy loading gets broken
let cellSize = getCellSize(this._fontFilter.fontDescription);
let cellsPerRow = Math.floor(allocation.width / cellSize);
// Ensure the rows cause a scroll
let heightInRows = Math.ceil((allocation.height + 1) / cellSize);
return Math.max(MAX_SEARCH_RESULTS, heightInRows * cellsPerRow);
},
_onSizeAllocate: function(scrolled, allocation) {
if (this._characters.length < this.initialSearchCount) {
this._maybeLoadMore();
}
},
_addSearchResult: function(result) {
let characters = Util.searchResultToArray(result);
this.setCharacters(this._characters.concat(characters));
},
_searchWithContext: function(context, count) {
this._startSpinner();
context.search(
count,
this._cancellable,
Lang.bind(this, function(context, res, user_data) {
this._stopSpinner();
try {
let result = context.search_finish(res);
this._addSearchResult(result);
} catch (e) {
log("Failed to search: " + e.message);
}
}));
},
searchByCategory: function(category) {
if ('scripts' in category) {
this.searchByScripts(category.scripts);
return;
}
let criteria = Gc.SearchCriteria.new_category(category.category);
this._searchContext = new Gc.SearchContext({ criteria: criteria });
this._searchWithContext(this._searchContext, this.initialSearchCount);
},
searchByKeywords: function(keywords) {
let criteria = Gc.SearchCriteria.new_keywords(keywords);
this._searchContext = new Gc.SearchContext({
criteria: criteria,
flags: Gc.SearchFlag.WORD
});
this._searchWithContext(this._searchContext, this.initialSearchCount);
},
searchByScripts: function(scripts) {
var criteria = Gc.SearchCriteria.new_scripts(scripts);
this._searchContext = new Gc.SearchContext({ criteria: criteria });
this._searchWithContext(this._searchContext, this.initialSearchCount);
},
cancelSearch: function() {
this._cancellable.cancel();
this._cancellable.reset();
}
});
var RecentCharacterListView = new Lang.Class({
Name: 'RecentCharacterListView',
Extends: Gtk.Bin,
Signals: {
'character-selected': { param_types: [ GObject.TYPE_STRING ] }
},
_init: function(params) {
let filtered = Params.filter(params, {
category: null,
fontFilter: null
});
params = Params.fill(params, {
hexpand: true, vexpand: false
});
this.parent(params);
this._fontFilter = filtered.fontFilter;
this._characterList = new CharacterListWidget({
hexpand: true,
vexpand: true,
fontDescription: this._fontFilter.fontDescription,
numRows: 0
});
this._characterList.connect('character-selected',
Lang.bind(this, function(w, c) {
this.emit('character-selected', c);
}));
this.add(this._characterList);
this._fontFilter.connect('filter-set',
Lang.bind(this, this._updateCharacterList));
this._category = filtered.category;
this._characters = [];
},
setCharacters: function(characters) {
let result = Gc.filter_characters(this._category, characters);
this._characters = Util.searchResultToArray(result);
this._updateCharacterList();
},
_updateCharacterList: function() {
let [fontDescription, characters] = this._fontFilter.apply(this, this._characters);
this._characterList.setFontDescription(fontDescription);
this._characterList.setCharacters(characters);
this.show_all();
}
});