/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */ /* vim: set et ts=4 sw=4: */ /* * Copyright (c) 2013,2014 Jonas Danielsson, Mattias Bengtsson. * * GNOME Maps 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. * * GNOME Maps 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 GNOME Maps; if not, see . * * Author: Jonas Danielsson * Mattias Bengtsson */ import gettext from 'gettext'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import GeocodeGlib from 'gi://GeocodeGlib'; import Gio from 'gi://Gio'; import Gtk from 'gi://Gtk'; import {Application} from './application.js'; import * as GeocodeFactory from './geocode.js'; import {Location} from './location.js'; import {Place} from './place.js'; import {PlaceStore} from './placeStore.js'; import {PlacePopover} from './placePopover.js'; import * as URIS from './uris.js'; import * as Utils from './utils.js'; const _ = gettext.gettext; // minimum number of characters to start completion const MIN_CHARS_COMPLETION = 3; // pattern matching CJK ideographic characters const IDEOGRAPH_PATTERN = /[\u3300-\u9fff]/ export class PlaceEntry extends Gtk.SearchEntry { set place(p) { if (!this._place && !p) return; /* if this entry belongs to a routing query entry, don't notify when * setting the same place, otherwise it will trigger twice when * initiating from the context menu */ if (!this._matchRoute && this._place && p && this._locEquals(this._place, p)) return; if (p) { if (p.name) this._placeText = p.name; else this._placeText = '%.5f, %.5f'.format(p.location.latitude, p.location.longitude); } else { this._placeText = ''; } if (this.text !== this._placeText) this._setTextWithoutTriggerSearch(this._placeText); this._place = p; this.notify('place'); } get place() { return this._place; } get popover() { return this._popover; } constructor(props) { let mapView = props.mapView; delete props.mapView; let maxChars = props.maxChars; delete props.maxChars; let matchRoute = props.matchRoute ?? false; delete props.matchRoute; super(props); this._mapView = mapView; this._matchRoute = matchRoute; this._filter = new Gtk.TreeModelFilter({ child_model: Application.placeStore }); this._filter.set_visible_func(this._completionVisibleFunc.bind(this)); this._popover = this._createPopover(maxChars); this.connect('search-changed', this._onSearchChanged.bind(this)); this._cache = {}; // clear cache when view moves, as result are location-dependent this._mapView.map.viewport.connect('notify::latitude', () => this._cache = {}); // clear cache when zoom level changes, to allow limiting location bias this._mapView.map.viewport.connect('notify::zoom-level', () => this._cache = {}); } _setTextWithoutTriggerSearch(text) { this._setText = text; this.text = text; } _onSearchChanged() { if (this._parse()) return; // wait for an ongoing search if (this._cancellable) return; // don't trigger a search when setting explicit text (such as reordering points) if (this.text === this._setText) return; /* start search if more than the threshold number of characters have * been entered, or if the first character is in the ideographic CJK * block, as for these, shorter strings could be meaningful */ if ((this.text.length >= MIN_CHARS_COMPLETION || (this.text.length > 0 && this.text[0].match(IDEOGRAPH_PATTERN))) && this.text !== this._placeText) { let cachedResults = this._cache[this.text]; if (cachedResults) { this.updateResults(cachedResults, this.text, true); } else { // if no previous search has been performed, show spinner if (!this._previousSearch || this._previousSearch.length < MIN_CHARS_COMPLETION || this._placeText) { this._popover.showSpinner(); } this._placeText = ''; this._doSearch(); } } else { this._popover.popdown(); this.grab_focus(); if (this.text.length === 0) this.place = null; this._previousSearch = null; } } _locEquals(placeA, placeB) { if (!placeA.location || !placeB.location) return false; return (placeA.location.latitude === placeB.location.latitude && placeA.location.longitude === placeB.location.longitude); } _createPopover(maxChars) { let popover = new PlacePopover({ entry: this, maxChars: maxChars }); popover.set_parent(this); this.set_key_capture_widget(popover); popover.connect('selected', (widget, place) => { this.place = place; popover.popdown(); }); return popover; } _onKeyPressed(controller, keyval, keycode, state) { return true; } _completionVisibleFunc(model, iter) { let place = model.get_value(iter, PlaceStore.Columns.PLACE); let type = model.get_value(iter, PlaceStore.Columns.TYPE); if (type !== PlaceStore.PlaceType.RECENT_ROUTE || (!this._matchRoute && type === PlaceStore.PlaceType.RECENT_ROUTE)) return false; if (place !== null) return place.match(this.text); else return false; } /** * Returns true if two locations are equal when rounded to displayes * coordinate precision */ _roundedLocEquals(locA, locB) { return '%.5f, %.5f'.format(locA.latitude, locA.longitude) === '%.5f, %.5f'.format(locB.latitude, locB.longitude) } _parse() { let parsed = false; if (this.text.startsWith('geo:')) { let location = new GeocodeGlib.Location(); try { location.set_from_uri(this.text); this.place = new Place({ location: location }); } catch(e) { let msg = _("Failed to parse Geo URI"); Utils.showDialog(msg, Gtk.MessageType.ERROR, this.get_toplevel()); } parsed = true; } if (this.text.startsWith('maps:')) { let query = URIS.parseMapsURI(this.text); if (query) { this.text = query; } else { let msg = _("Failed to parse Maps URI"); Utils.showDialog(msg, Gtk.MessageType.ERROR, this.get_toplevel()); } parsed = true; } let parsedLocation = Place.parseCoordinates(this.text); if (parsedLocation) { /* if the place was a parsed OSM coordinate URL, it will have * gotten re-written as bare coordinates and trigger a search-changed, * in this case don't re-set the place, as it will loose the zoom * level from the URL if set */ if (!this.place || !this._roundedLocEquals(parsedLocation, this.place.location)) this.place = new Place({ location: parsedLocation }); parsed = true; } if (this.text.startsWith('http://') || this.text.startsWith('https://')) { if (this._cancellable) this._cancellable.cancel(); this._cancellable = null; Place.parseHttpURL(this.text, (place, error) => { if (place) this.place = place; else Utils.showDialog(error, Gtk.MessageType.ERROR, this.get_toplevel()); }); /* don't cancel ongoing search, as we have started an async * operation looking up the OSM object */ return true; } if (parsed && this._cancellable) this._cancellable.cancel(); return parsed; } _doSearch() { if (this._cancellable) this._cancellable.cancel(); this._cancellable = new Gio.Cancellable(); this._previousSearch = this.text; GeocodeFactory.getGeocoder().search(this.text, this._mapView.map.viewport.latitude, this._mapView.map.viewport.longitude, this._cancellable, (places, error) => { this._cancellable = null; if (error) { this.place = null; this._popover.showError(); } else { this.updateResults(places, this.text, true); // cache results for later this._cache[this._previousSearch] = places; } // if search input has been updated, trigger a refresh if (this.text !== this._previousSearch) this._onSearchChanged(); }); } /** * Update results popover * places array of places from search result * searchText original search string to highlight in results * includeContactsAndRoute whether to include contacts and recent routes * among results */ updateResults(places, searchText, includeContactsAndRoutes) { if (!places) { this.place = null; this._popover.showNoResult(); return; } let completedPlaces = []; if (includeContactsAndRoutes) { this._filter.refilter(); this._filter.foreach((model, path, iter) => { let place = model.get_value(iter, PlaceStore.Columns.PLACE); let type = model.get_value(iter, PlaceStore.Columns.TYPE); completedPlaces.push({ place: place, type: type }); }); } let placeStore = Application.placeStore; places.forEach((place) => { let type; if (placeStore.exists(place, PlaceStore.PlaceType.RECENT)) type = PlaceStore.PlaceType.RECENT; else if (placeStore.exists(place, PlaceStore.PlaceType.FAVORITE)) type = PlaceStore.PlaceType.FAVORITE; else type = PlaceStore.PlaceType.ANY; completedPlaces.push({ place: place, type: type }); }); this._popover.updateResult(completedPlaces, searchText); this._popover.showResult(); } } GObject.registerClass({ Properties: { 'place': GObject.ParamSpec.object('place', 'Place', 'The selected place', GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, GeocodeGlib.Place) } }, PlaceEntry);