diff --git a/data/Application.css b/data/Application.css index 48e10c9..dce1ad4 100644 --- a/data/Application.css +++ b/data/Application.css @@ -108,3 +108,9 @@ search-result-item.land-feature image { background-color: @LIME_500; -gtk-icon-shadow: 0 1px 3px @LIME_700; } + +/* Avoid styling the button frame we use for custom locations */ +map-marker menubutton button.toggle { + background: none; + box-shadow: none; +} \ No newline at end of file diff --git a/po/POTFILES b/po/POTFILES index 2379a5f..e33b4eb 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1,4 +1,7 @@ src/Application.vala src/MainWindow.vala src/MapWidget.vala +src/Objects/MarkerData.vala src/SearchResultItem.vala +src/Widgets/Marker.vala +src/Widgets/SearchResultMarker.vala \ No newline at end of file diff --git a/src/MainWindow.vala b/src/MainWindow.vala index c450261..98e7e33 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -20,21 +20,31 @@ public class Maps.MainWindow : Adw.ApplicationWindow { private const ActionEntry[] ACTION_ENTRIES = { { "search", on_search_activate }, + { "pin", on_pin_activate }, }; private int current_busy_reason = 0; private ListStore location_store; private Cancellable? search_cancellable = null; private Gtk.Button current_location; + private Gtk.ToggleButton pin_location; private Gtk.Spinner spinner; private Gtk.SearchEntry search_entry; private Gtk.Popover search_res_popover; private MapWidget map_widget; + private Gtk.GestureClick gesture_click; + private Gtk.EventControllerKey key_controller; + construct { title = _("Maps"); add_action_entries (ACTION_ENTRIES, this); + gesture_click = new Gtk.GestureClick (); + key_controller = new Gtk.EventControllerKey (); + ((Gtk.Widget)this).add_controller (gesture_click); + ((Gtk.Widget)this).add_controller (key_controller); + location_store = new ListStore (typeof (Geocode.Place)); current_location = new Gtk.Button.from_icon_name ("find-location-symbolic") { @@ -42,6 +52,13 @@ public class Maps.MainWindow : Adw.ApplicationWindow { valign = CENTER }; + pin_location = new Gtk.ToggleButton () { + tooltip_text = _("Pin a location"), + icon_name = "view-pin-symbolic", + action_name = "win.pin", + valign = CENTER + }; + spinner = new Gtk.Spinner (); search_entry = new Gtk.SearchEntry () { @@ -160,6 +177,7 @@ public class Maps.MainWindow : Adw.ApplicationWindow { title_widget = search_clamp }; headerbar.pack_start (current_location); + headerbar.pack_start (pin_location); headerbar.pack_end (menu_button); headerbar.pack_end (spinner); @@ -239,7 +257,7 @@ public class Maps.MainWindow : Adw.ApplicationWindow { search_listview.activate.connect ((pos) => { var place = (Geocode.Place) selection_model.get_item (pos); - map_widget.go_to_location (place.location); + map_widget.go_to_place (place); search_res_popover.popdown (); }); @@ -365,4 +383,44 @@ public class Maps.MainWindow : Adw.ApplicationWindow { var search_result_item = (SearchResultItem) list_item.child; search_result_item.place = (Geocode.Place) list_item.item; } + + // We handle a pin mode at the level of the window, so we can disable the pin button, + // or cancel if the user press escape, or clicks anywhere but the map + private void on_pin_activate () { + pin_location.active = true; + + set_cursor_from_name ("crosshair"); + map_widget.set_cursor_from_name ("crosshair"); + + gesture_click.released.connect (on_click); + key_controller.key_released.connect (on_keypress); + map_widget.leaving_pin_mode.connect (on_exit_pin_mode); + + map_widget.enter_pin_mode (); + } + + private void on_exit_pin_mode () { + set_cursor_from_name ("default"); + pin_location.active = false; + + gesture_click.released.disconnect (on_click); + key_controller.key_released.disconnect (on_keypress); + map_widget.leaving_pin_mode.disconnect (on_exit_pin_mode); + } + + // If this handler fires, then the user did not click on the map + private void on_click (int n_press, double x, double y) { + debug ("%.2f, %.2f", x, y); + map_widget.on_exit_pin_mode (); + on_exit_pin_mode (); + return; + } + + // We only exit on Escape, as people may need to use other keys to navigate on the map from the keyboard + private void on_keypress (uint keyval, uint keycode, Gdk.ModifierType state) { + if (keyval == Gdk.Key.Escape) { + map_widget.on_exit_pin_mode (); + on_exit_pin_mode (); + } + } } diff --git a/src/MapWidget.vala b/src/MapWidget.vala index 6f69f2b..71972c8 100644 --- a/src/MapWidget.vala +++ b/src/MapWidget.vala @@ -19,14 +19,20 @@ public class Maps.MapWidget : Gtk.Box { private Shumate.SimpleMap map_widget; private Shumate.Map base_map; // Displays the "pin" icon at a specified place by search - private Shumate.MarkerLayer pin_layer; + private Shumate.MarkerLayer search_layer; // Displays the position of current location private Shumate.MarkerLayer location_layer; - + // Displays the position of user-pinned layers + private Shumate.MarkerLayer user_layer; private GClue.Location? location = null; + private Gtk.GestureClick gesture_click; + + private MarkerManager marker_manager; private bool is_watching_location = false; + public signal void leaving_pin_mode (); + // The Royal Observatory private const double DEFAULT_LATITUDE = 51.2840; private const double DEFAULT_LONGITUDE = 0.0005; @@ -40,16 +46,25 @@ public class Maps.MapWidget : Gtk.Box { hexpand = true }; + gesture_click = new Gtk.GestureClick (); + map_widget.add_controller (gesture_click); + append (map_widget); base_map = map_widget.map; } public void init_marker_layers () { - pin_layer = new Shumate.MarkerLayer (map_widget.viewport); - map_widget.add_overlay_layer (pin_layer); + search_layer = new Shumate.MarkerLayer (map_widget.viewport); + map_widget.add_overlay_layer (search_layer); location_layer = new Shumate.MarkerLayer (map_widget.viewport); map_widget.add_overlay_layer (location_layer); + + user_layer = new Shumate.MarkerLayer (map_widget.viewport); + map_widget.add_overlay_layer (user_layer); + + marker_manager = new MarkerManager (user_layer); + marker_manager.populate_layer.begin (); } // Set the initial location of the map widget. @@ -146,13 +161,21 @@ public class Maps.MapWidget : Gtk.Box { // we get an uri that looks like geo:///lat,lon, remove slashes location.set_from_uri (uri.replace ("///", "")); - go_to_location (location); + var place = new Geocode.Place.with_location (_("Location"), Geocode.PlaceType.UNKNOWN, location); + go_to_place (place); } - public void go_to_location (Geocode.Location location) { - pin_layer.remove_all (); - mark_pin_at (location.latitude, location.longitude); - base_map.go_to_full (location.latitude, location.longitude, DEFAULT_ZOOM_LEVEL); + public void go_to_place (Geocode.Place place) { + search_layer.remove_all (); + var search_result_marker = new SearchResultMarker (place); + + search_result_marker.create_pin.connect ((markerdata) => { + search_layer.remove_all (); + marker_manager.add_marker (markerdata); + }); + + search_layer.add_marker (search_result_marker); + base_map.go_to_full (place.location.latitude, place.location.longitude, DEFAULT_ZOOM_LEVEL); } // Saves the latest state of the map. @@ -177,18 +200,24 @@ public class Maps.MapWidget : Gtk.Box { location_layer.add_marker (marker); } - private void mark_pin_at (double latitude, double longitude) { - var image = new Gtk.Image.from_icon_name ("pointer") { - icon_size = Gtk.IconSize.LARGE - }; + public void enter_pin_mode () { + map_widget.map.set_cursor_from_name ("crosshair"); + gesture_click.released.connect (on_click); + } - var marker = new Shumate.Marker () { - latitude = latitude, - longitude = longitude, - child = image, - selectable = true - }; + public void on_exit_pin_mode () { + map_widget.map.set_cursor_from_name ("default"); + gesture_click.released.disconnect (on_click); + leaving_pin_mode (); + } + + private void on_click (int n_press, double x, double y) { + on_exit_pin_mode (); + + double latitude, longitude; + map_widget.viewport.widget_coords_to_location (map_widget, x, y, out latitude, out longitude); + marker_manager.add_marker (new MarkerData (latitude, longitude)); - pin_layer.add_marker (marker); + return; } } diff --git a/src/Objects/MarkerData.vala b/src/Objects/MarkerData.vala new file mode 100644 index 0000000..a31190d --- /dev/null +++ b/src/Objects/MarkerData.vala @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + * 2018-2025 Ryo Nakano + * 2014-2015 Atlas Developers + */ + +/** + * MarkerData represents the informations for a specific location + * + * It is used by {@link Maps.MarkerManager} to save, load, and represent locations on a map. + * + * It can be converted from or into a {@link Json.Object} for convenience + */ +public class Maps.MarkerData : Object { + + public double latitude {get; set;} + public double longitude {get; set;} + public string name {get; set; default = _("New location");} + public string comments {get; set; default = "";} + + public MarkerData (double latitude, double longitude) { + Object ( + latitude: latitude, + longitude: longitude + ); + } + + public MarkerData.from_json (Json.Object node) { + latitude = node.get_double_member ("lat"); + longitude = node.get_double_member ("long"); + name = node.get_string_member ("name"); + comments = node.get_string_member ("comments"); + } + + public Json.Object to_json () { + var builder = new Json.Builder (); + + builder.begin_object (); + builder.set_member_name ("lat"); + builder.add_double_value (latitude); + builder.set_member_name ("long"); + builder.add_double_value (longitude); + builder.set_member_name ("name"); + builder.add_string_value (name); + builder.set_member_name ("comments"); + builder.add_string_value (comments); + builder.end_object (); + + return builder.get_root ().get_object (); + } +} diff --git a/src/SearchResultItem.vala b/src/SearchResultItem.vala index 46ddc5f..0e87e98 100644 --- a/src/SearchResultItem.vala +++ b/src/SearchResultItem.vala @@ -22,6 +22,7 @@ public class Maps.SearchResultItem : Granite.Bin { image.icon_name = place.icon.to_string () + "-symbolic"; name_label.label = place.name; info_label.label = "%s, %s, %s".printf (street, postal_code, town); + place.street_address = info_label.label; // Not add because this widget gets recycled css_classes = {placetype_to_cssclass (place.place_type)}; diff --git a/src/Services/MarkerManager.vala b/src/Services/MarkerManager.vala new file mode 100644 index 0000000..89a158d --- /dev/null +++ b/src/Services/MarkerManager.vala @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + * 2018-2025 Ryo Nakano + * 2014-2015 Atlas Developers + */ + +/** + * MarkerManager manages locations on a {@link Shumate.MarkerLayer} + * + * It loads {@link Maps.MarkerData} from {@link Maps.Storage}, from which it creates {@link Maps.Marker} widgets and adds them to the layer + * + */ +public class Maps.MarkerManager : Object { + + private static uint debounce_timer_id; + + public signal void pin_created (Marker marker); + + private Storage storage; + private unowned Shumate.MarkerLayer markerlayer; + + public MarkerManager (Shumate.MarkerLayer markerlayer) { + this.markerlayer = markerlayer; + } + + construct { + storage = new Storage (); + } + + public async void populate_layer () { + var markerdata_array = storage.load (); + + foreach (var markerdata_note in markerdata_array.get_elements ()) { + var markerdata = new MarkerData.from_json (markerdata_note.get_object ()); + add_marker (markerdata); + } + + // Only care about new pins after loading everything + pin_created.connect (on_pin_created); + } + + public void add_marker (MarkerData markerdata) { + var marker = new Marker (markerdata); + markerlayer.add_marker (marker); + + marker.changed.connect (save_debounce); + marker.deletion_requested.connect (remove_marker); + + pin_created (marker); + } + + public void remove_marker (Shumate.Marker marker) { + markerlayer.remove_marker (marker); + save_debounce (); + } + + public void save_debounce () { + if (debounce_timer_id != 0) { + GLib.Source.remove (debounce_timer_id); + } + + debounce_timer_id = Timeout.add (750, debounce_handler); + } + + public bool debounce_handler () { + debounce_timer_id = 0; + immediately_save (); + return GLib.Source.REMOVE; + } + + public void immediately_save () { + debug ("Saving"); + var json_array = new Json.Array (); + + foreach (var marker in markerlayer.get_markers ()) { + var markerdata = ((Maps.Marker)marker).to_markerdata (); + json_array.add_object_element (markerdata.to_json ()); + } + + storage.save (json_array); + } + + private void on_pin_created (Marker marker) { + marker.popup (); + marker.grab_entry (); + save_debounce (); + } + + ~MarkerManager () { + immediately_save (); + } +} diff --git a/src/Services/Storage.vala b/src/Services/Storage.vala new file mode 100644 index 0000000..1313204 --- /dev/null +++ b/src/Services/Storage.vala @@ -0,0 +1,87 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + * 2018-2025 Ryo Nakano + * 2014-2015 Atlas Developers + */ + +/** + * A wrapper for disk operations for the custom locations from {@link Maps.MarkerManager} + * + * It can save () and load () a {@link Json.Array} object containing {@link Maps.MarkerData} + */ +public class Maps.Storage : Object { + + private const string STORAGE_FILENAME = "locations.json"; + private File datadir; + private string storage_file_path; + + construct { + var path_data = Environment.get_user_data_dir (); + datadir = File.new_for_path (path_data); + ensure_datadir (); + + storage_file_path = GLib.Path.build_path (Path.DIR_SEPARATOR_S, path_data, STORAGE_FILENAME); + print ("\nLocations are stored at %s", storage_file_path); + } + + /** + * Persistently check for the data directory and create if there is none + */ + private void ensure_datadir () { + debug ("do we have a data directory?"); + if (datadir.query_exists ()) { + debug ("Yes, nevermind"); + return; + } + + try { + datadir.make_directory_with_parents (); + debug ("yes we do now"); + + } catch (Error e) { + warning ("Failed to prepare target data directory %s", e.message); + } + } + + /** + * Converts a Json.Node into a string and take care of saving it + */ + public void save (Json.Array json_data) { + ensure_datadir (); + debug ("Writing %u elements (Should be same number as locations)", json_data.get_length ()); + + try { + var generator = new Json.Generator (); + var node = new Json.Node (Json.NodeType.ARRAY); + node.set_array (json_data); + generator.set_root (node); + generator.to_file (storage_file_path); + + } catch (Error e) { + warning ("Failed to save locations: %s", e.message); + } + } + + /** + * Grab from storage, into a Json.Node we can parse. Insist if necessary + */ + public Json.Array load () { + + var parser = new Json.Parser (); + var array = new Json.Array (); + + try { + parser.load_from_mapped_file (storage_file_path); + var node = parser.get_root (); + array = node.get_array (); + + } catch (Error e) { + warning ("Failed to load from storage %s", e.message); + + } + + debug ("Retrieved %ui elements", array.get_length ()); + return array; + } +} diff --git a/src/Widgets/Marker.vala b/src/Widgets/Marker.vala new file mode 100644 index 0000000..a26baf1 --- /dev/null +++ b/src/Widgets/Marker.vala @@ -0,0 +1,178 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + * 2018-2025 Ryo Nakano + * 2014-2015 Atlas Developers + */ + +/** + * Marker is a subclass of {@link Shumate.Marker} built from a {@link Maps.MarkerData} + * + * It displays a pointer on the map which when clicked shows a popover presenting the MarkerData informations. + * + * It is created by {@link Maps.MarkerManager} from the content of a {@link Maps.Storage} + */ +public class Maps.Marker : Shumate.Marker { + + public signal void changed (); + public signal void deletion_requested (Shumate.Marker marker); + + private Gtk.Popover popover; + private Gtk.Entry entry_name; + private Granite.HyperTextView textview_comments; + + public string loc_name { + owned get { + return entry_name.text.dup (); + } + set { + entry_name.text = value; + changed (); + } + } + + public string loc_comments { + owned get { + return textview_comments.buffer.text.dup (); + } + set { + textview_comments.buffer.text = value; + changed (); + } + } + + public Marker (MarkerData markerdata) { + Object ( + latitude: markerdata.latitude, + longitude: markerdata.longitude, + loc_name: markerdata.name, + loc_comments: markerdata.comments + ); + } + + construct { + var box = new Granite.Box (Gtk.Orientation.VERTICAL, Granite.Box.Spacing.SINGLE) { + margin_top = 10, + margin_bottom = 10, + margin_start = 10, + margin_end = 10, + width_request = 210 + }; + + entry_name = new Gtk.Entry () { + show_emoji_icon = true + }; + + var label_location = new Granite.HeaderLabel (_("Location name:")) { + mnemonic_widget = entry_name + }; + + var box_name = new Granite.Box (VERTICAL, NONE); + box_name.append (label_location); + box_name.append (entry_name); + + textview_comments = new Granite.HyperTextView () { + accepts_tab = false, + wrap_mode = WORD_CHAR, + top_margin = 3, + right_margin = 3, + bottom_margin = 3, + left_margin = 3, + }; + + var scrolled_comments = new Gtk.ScrolledWindow () { + child = textview_comments, + hscrollbar_policy = NEVER + }; + + var frame_comments = new Gtk.Frame (null) { + child = scrolled_comments, + height_request = 70, + hexpand = true, + vexpand = true + }; + + var label_comments = new Granite.HeaderLabel (_("Comments:")) { + mnemonic_widget = textview_comments + }; + + var box_comments = new Granite.Box (VERTICAL, NONE); + box_comments.append (label_comments); + box_comments.append (frame_comments); + + var delete_button = new Gtk.Button.from_icon_name ("user-trash-symbolic") { + tooltip_text = _("Delete this location"), + }; + delete_button.add_css_class (Granite.CssClass.DESTRUCTIVE); + + var box_toolbar = new Granite.Box (HORIZONTAL, HALF) { + halign = Gtk.Align.START, + }; + box_toolbar.append (delete_button); + + box.append (box_name); + box.append (box_comments); + box.append (box_toolbar); + + popover = new Gtk.Popover () { + child = box + }; + + var image = new Gtk.Image.from_icon_name ("pointer") { + icon_size = Gtk.IconSize.LARGE + }; + + var menubutton = new Gtk.MenuButton () { + popover = popover, + child = image, + has_frame = false, + }; + + popover.position = Gtk.PositionType.TOP; + child = menubutton; + textview_comments.grab_focus (); + + entry_name.bind_property ("text", + this, "tooltip-text", + GLib.BindingFlags.DEFAULT | GLib.BindingFlags.SYNC_CREATE + ); + + entry_name.changed.connect (on_changed); + textview_comments.buffer.changed.connect (on_changed); + delete_button.clicked.connect (delete_marker); + popover.show.connect (on_show); + entry_name.activate.connect (popover.popdown); + } + + public void delete_marker () { + popover.popdown (); + deletion_requested (this); + } + + public void popup () { + popover.popup (); + } + + // We use a dedicated handler as there may be a lot of markers + // To avoid the infamous vala lambda leak bug + public void on_changed () { + changed (); + } + + private void on_show () { + // Prevent entry_name from stealing the show by selecting its content + entry_name.select_region (-1, -1); + textview_comments.grab_focus (); + } + + public void grab_entry () { + entry_name.grab_focus (); + } + + public MarkerData to_markerdata () { + return new MarkerData (latitude, longitude) { + name = loc_name, + comments = loc_comments + }; + } +} diff --git a/src/Widgets/SearchResultMarker.vala b/src/Widgets/SearchResultMarker.vala new file mode 100644 index 0000000..97399de --- /dev/null +++ b/src/Widgets/SearchResultMarker.vala @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + * 2018-2025 Ryo Nakano + * 2014-2015 Atlas Developers + */ + +/** + * SearchResultMarker is a subclass of {@link Shumate.Marker} built from a {@link Geocode.Location} + * + * It displays a pointer on the map which when clicked shows a popover offering the user to create a new pin for this location + * + * It is created by {@link Maps.Map_Widget} when user selects a result of a search + */ +public class Maps.SearchResultMarker : Shumate.Marker { + + public signal void create_pin (MarkerData markerdata); + + private Granite.HeaderLabel headerlabel; + private Gtk.Image icon; + private Gtk.Popover popover; + + public string loc_name { + get { + return headerlabel.label; + } + set { + headerlabel.label = value; + } + } + + public string loc_address { + get { + return headerlabel.secondary_text; + } + set { + headerlabel.secondary_text = value; + } + } + + public string loc_icon { + owned get { + return icon.icon_name.dup (); + } + set { + icon.icon_name = value; + } + } + + public SearchResultMarker (Geocode.Place place) { + Object ( + latitude: place.location.latitude, + longitude: place.location.longitude, + loc_name: place.name ?? _("Unknown"), + loc_address: place.street_address ?? _("Unknown"), + loc_icon: place.icon.to_string () + "-symbolic" + ); + } + + construct { + var box = new Granite.Box (VERTICAL, HALF) { + margin_top = 10, + margin_bottom = 10, + margin_start = 10, + margin_end = 10, + width_request = 90 + }; + + var box_info = new Granite.Box (HORIZONTAL, NONE); + + headerlabel = new Granite.HeaderLabel (""); + var button = new Gtk.Button.with_label (_("Pin this location")); + button.add_css_class (Granite.CssClass.SUGGESTED); + + icon = new Gtk.Image () { + icon_size = LARGE + }; + + box_info.append (icon); + box_info.append (headerlabel); + + box.append (box_info); + box.append (button); + + popover = new Gtk.Popover () { + child = box + }; + + var image = new Gtk.Image.from_icon_name ("pointer") { + icon_size = Gtk.IconSize.LARGE + }; + + var menubutton = new Gtk.MenuButton () { + popover = popover, + child = image, + has_frame = false, + tooltip_text = loc_name + }; + + popover.position = Gtk.PositionType.TOP; + child = menubutton; + + button.clicked.connect (on_button_click); + show.connect (popover.popup); + } + + private void on_button_click () { + popover.popdown (); + var new_marker_data = new MarkerData (latitude, longitude) { + name = loc_name, + comments = loc_address + }; + create_pin (new_marker_data); + } +} diff --git a/src/meson.build b/src/meson.build index 61815a5..3312bf4 100644 --- a/src/meson.build +++ b/src/meson.build @@ -20,6 +20,8 @@ dependencies = [ dependency('libadwaita-1'), dependency('libgeoclue-2.0'), dependency('shumate-1.0'), + # Required by Storage + dependency('json-glib-1.0'), ] sources = files( @@ -29,6 +31,11 @@ sources = files( 'MapWidget.vala', 'SearchResultItem.vala', 'Util.vala', + 'Objects' / 'MarkerData.vala', + 'Services' / 'MarkerManager.vala', + 'Services' / 'Storage.vala', + 'Widgets' / 'Marker.vala', + 'Widgets' / 'SearchResultMarker.vala', ) executable(