Parcourir la source

Maps: Add search panel

Bastiaan van der Plaat il y a 1 an
Parent
commit
deb7ecfbe9

+ 4 - 0
Userland/Applications/Maps/CMakeLists.txt

@@ -4,9 +4,13 @@ serenity_component(
     TARGETS Maps
 )
 
+compile_gml(SearchPanel.gml SearchPanelGML.cpp)
+
 set(SOURCES
     main.cpp
     MapWidget.cpp
+    SearchPanelGML.cpp
+    SearchPanel.cpp
     UsersMapWidget.cpp
 )
 

+ 4 - 0
Userland/Applications/Maps/MapWidget.cpp

@@ -16,6 +16,8 @@
 #include <LibGfx/ImageFormats/ImageDecoder.h>
 #include <LibProtocol/Request.h>
 
+namespace Maps {
+
 // Math helpers
 // https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Pseudo-code
 static double longitude_to_tile_x(double longitude, int zoom)
@@ -531,3 +533,5 @@ void MapWidget::paint_event(GUI::PaintEvent& event)
         paint_scale(painter);
     paint_panels(painter);
 }
+
+}

+ 9 - 4
Userland/Applications/Maps/MapWidget.h

@@ -15,6 +15,8 @@
 #include <LibProtocol/Request.h>
 #include <LibProtocol/RequestClient.h>
 
+namespace Maps {
+
 class MapWidget : public GUI::Frame
     , public Config::Listener {
     C_OBJECT(MapWidget);
@@ -63,15 +65,16 @@ public:
         LatLng latlng;
         Optional<String> tooltip {};
         RefPtr<Gfx::Bitmap> image { nullptr };
+        Optional<String> name {};
     };
     void add_marker(Marker const& marker)
     {
         m_markers.append(marker);
         update();
     }
-    void clear_markers()
+    void remove_markers_with_name(StringView name)
     {
-        m_markers.clear();
+        m_markers.remove_all_matching([name](auto const& marker) { return marker.name == name; });
         update();
     }
 
@@ -185,7 +188,9 @@ private:
     Vector<Panel> m_panels;
 };
 
+}
+
 template<>
-struct AK::Traits<MapWidget::TileKey> : public GenericTraits<MapWidget::TileKey> {
-    static unsigned hash(MapWidget::TileKey const& t) { return t.hash(); }
+struct AK::Traits<Maps::MapWidget::TileKey> : public GenericTraits<Maps::MapWidget::TileKey> {
+    static unsigned hash(Maps::MapWidget::TileKey const& t) { return t.hash(); }
 };

+ 132 - 0
Userland/Applications/Maps/SearchPanel.cpp

@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2023, Bastiaan van der Plaat <bastiaan.v.d.plaat@gmail.com>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "SearchPanel.h"
+#include <AK/JsonParser.h>
+
+namespace Maps {
+
+ErrorOr<NonnullRefPtr<SearchPanel>> SearchPanel::create()
+{
+    auto widget = TRY(try_create());
+    TRY(widget->setup());
+    return widget;
+}
+
+ErrorOr<void> SearchPanel::setup()
+{
+    m_request_client = TRY(Protocol::RequestClient::try_create());
+
+    m_search_textbox = *find_descendant_of_type_named<GUI::TextBox>("search_textbox");
+    m_search_button = *find_descendant_of_type_named<GUI::Button>("search_button");
+    m_start_container = *find_descendant_of_type_named<GUI::Frame>("start_container");
+    m_empty_container = *find_descendant_of_type_named<GUI::Frame>("empty_container");
+    m_places_list = *find_descendant_of_type_named<GUI::ListView>("places_list");
+
+    m_empty_container->set_visible(false);
+    m_places_list->set_visible(false);
+
+    m_search_textbox->on_return_pressed = [this]() {
+        search(MUST(String::from_deprecated_string(m_search_textbox->text())));
+    };
+    m_search_button->on_click = [this](unsigned) {
+        search(MUST(String::from_deprecated_string(m_search_textbox->text())));
+    };
+
+    m_places_list->set_item_height(m_places_list->font().preferred_line_height() * 2 + m_places_list->vertical_padding());
+    m_places_list->on_selection_change = [this]() {
+        auto const& index = m_places_list->selection().first();
+        if (!index.is_valid())
+            return;
+        on_selected_place_change(m_places.at(index.row()));
+    };
+
+    return {};
+}
+
+void SearchPanel::search(StringView query)
+{
+    // Show start container when empty query
+    if (query.is_empty()) {
+        m_start_container->set_visible(true);
+        m_empty_container->set_visible(false);
+        m_places_list->set_visible(false);
+        return;
+    }
+    m_start_container->set_visible(false);
+
+    // Start HTTP GET request to load people.json
+    HashMap<DeprecatedString, DeprecatedString> headers;
+    headers.set("User-Agent", "SerenityOS Maps");
+    headers.set("Accept", "application/json");
+    URL url(MUST(String::formatted("https://nominatim.openstreetmap.org/search?q={}&format=json", AK::URL::percent_encode(query, AK::URL::PercentEncodeSet::Query))));
+    auto request = m_request_client->start_request("GET", url, headers, {});
+    VERIFY(!request.is_null());
+    m_request = request;
+    request->on_buffered_request_finish = [this, request, url](bool success, auto, auto&, auto, ReadonlyBytes payload) {
+        m_request.clear();
+        if (!success) {
+            dbgln("Maps: Can't load: {}", url);
+            return;
+        }
+
+        // Parse JSON data
+        JsonParser parser(payload);
+        auto result = parser.parse();
+        if (result.is_error()) {
+            dbgln("Maps: Can't parse JSON: {}", url);
+            return;
+        }
+
+        // Show empty label when no places are found
+        auto json_places = result.release_value().as_array();
+        if (json_places.size() == 0) {
+            m_empty_container->set_visible(true);
+            m_places_list->set_visible(false);
+            return;
+        }
+
+        // Parse places from JSON data
+        m_places.clear();
+        m_places_names.clear();
+        for (size_t i = 0; i < json_places.size(); i++) {
+            // FIXME: Handle JSON parsing errors
+            auto const& json_place = json_places.at(i).as_object();
+
+            String name = MUST(String::from_deprecated_string(json_place.get_deprecated_string("display_name"sv).release_value()));
+            MapWidget::LatLng latlng = { json_place.get_deprecated_string("lat"sv).release_value().to_double().release_value(),
+                json_place.get_deprecated_string("lon"sv).release_value().to_double().release_value() };
+
+            // Calculate the right zoom level for bounding box
+            auto const& json_boundingbox = json_place.get_array("boundingbox"sv);
+            MapWidget::LatLngBounds bounds = {
+                { json_boundingbox->at(0).as_string().to_double().release_value(),
+                    json_boundingbox->at(2).as_string().to_double().release_value() },
+                { json_boundingbox->at(1).as_string().to_double().release_value(),
+                    json_boundingbox->at(3).as_string().to_double().release_value() }
+            };
+
+            m_places.append({ name, latlng, bounds.get_zoom() });
+            m_places_names.append(MUST(String::formatted("{}\n{:.5}, {:.5}", name, latlng.latitude, latlng.longitude)));
+        }
+        on_places_change(m_places);
+
+        // Update and show places list
+        m_empty_container->set_visible(false);
+        m_places_list->set_model(*GUI::ItemListModel<String>::create(m_places_names));
+        m_places_list->set_visible(true);
+    };
+    request->set_should_buffer_all_input(true);
+    request->on_certificate_requested = []() -> Protocol::Request::CertificateAndKey { return {}; };
+}
+
+void SearchPanel::reset()
+{
+    m_search_textbox->set_text(""sv);
+    search(""sv);
+}
+
+}

+ 63 - 0
Userland/Applications/Maps/SearchPanel.gml

@@ -0,0 +1,63 @@
+@Maps::SearchPanel {
+    min_width: 100
+    preferred_width: 200
+    max_width: 350
+    layout: @GUI::VerticalBoxLayout {
+        spacing: 2
+    }
+
+    @GUI::Frame {
+        frame_style: "SunkenPanel"
+        fixed_height: 28
+        layout: @GUI::HorizontalBoxLayout {
+            margins: [2]
+            spacing: 2
+        }
+
+        @GUI::TextBox {
+            name: "search_textbox"
+            placeholder: "Search a place..."
+        }
+
+        @GUI::Button {
+            name: "search_button"
+            icon_from_path: "/res/icons/16x16/find.png"
+            fixed_width: 24
+        }
+    }
+
+    //  Start, empty and places are toggled in visibility
+    @GUI::Frame {
+        name: "start_container"
+        frame_style: "SunkenPanel"
+        layout: @GUI::VerticalBoxLayout {
+            margins: [4]
+        }
+
+        @GUI::Label {
+            text: "Enter a search query to search for places..."
+            text_alignment: "CenterLeft"
+        }
+    }
+
+    @GUI::Frame {
+        name: "empty_container"
+        frame_style: "SunkenPanel"
+        layout: @GUI::VerticalBoxLayout {
+            margins: [4]
+        }
+
+        @GUI::Label {
+            text: "Can't find any places with the search query"
+            text_alignment: "CenterLeft"
+        }
+    }
+
+    @GUI::ListView {
+        name: "places_list"
+        horizontal_padding: 6
+        vertical_padding: 4
+        should_hide_unnecessary_scrollbars: true
+        alternating_row_colors: false
+    }
+}

+ 55 - 0
Userland/Applications/Maps/SearchPanel.h

@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2023, Bastiaan van der Plaat <bastiaan.v.d.plaat@gmail.com>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "MapWidget.h"
+#include <LibGUI/Button.h>
+#include <LibGUI/Frame.h>
+#include <LibGUI/ItemListModel.h>
+#include <LibGUI/ListView.h>
+#include <LibGUI/TextBox.h>
+#include <LibProtocol/Request.h>
+#include <LibProtocol/RequestClient.h>
+
+namespace Maps {
+
+class SearchPanel final : public GUI::Widget {
+    C_OBJECT(SearchPanel)
+
+public:
+    static ErrorOr<NonnullRefPtr<SearchPanel>> create();
+
+    void search(StringView query);
+    void reset();
+
+    struct Place {
+        String name;
+        MapWidget::LatLng latlng;
+        int zoom;
+    };
+    Function<void(Vector<Place> const&)> on_places_change;
+    Function<void(Place const&)> on_selected_place_change;
+
+private:
+    SearchPanel() = default;
+    static ErrorOr<NonnullRefPtr<SearchPanel>> try_create();
+
+    ErrorOr<void> setup();
+
+    RefPtr<Protocol::RequestClient> m_request_client;
+    RefPtr<Protocol::Request> m_request;
+    RefPtr<GUI::TextBox> m_search_textbox;
+    RefPtr<GUI::Button> m_search_button;
+    RefPtr<GUI::Frame> m_start_container;
+    RefPtr<GUI::Frame> m_empty_container;
+    RefPtr<GUI::ListView> m_places_list;
+    RefPtr<GUI::ItemListModel<String>> m_places_names_model;
+    Vector<Place> m_places;
+    Vector<String> m_places_names;
+};
+
+}

+ 5 - 1
Userland/Applications/Maps/UsersMapWidget.cpp

@@ -8,6 +8,8 @@
 #include <AK/JsonParser.h>
 #include <LibDesktop/Launcher.h>
 
+namespace Maps {
+
 UsersMapWidget::UsersMapWidget(Options const& options)
     : MapWidget::MapWidget(options)
 {
@@ -65,7 +67,7 @@ void UsersMapWidget::add_users_to_map()
         return;
 
     for (auto const& user : m_users.value()) {
-        MapWidget::Marker marker = { user.coordinates, user.nick };
+        MapWidget::Marker marker = { user.coordinates, user.nick, {}, "users"_string };
         if (!user.contributor)
             marker.image = m_marker_gray_image;
         add_marker(marker);
@@ -76,3 +78,5 @@ void UsersMapWidget::add_users_to_map()
         { { "https://github.com/SerenityOS/user-map" } },
         "users"_string });
 }
+
+}

+ 5 - 1
Userland/Applications/Maps/UsersMapWidget.h

@@ -8,6 +8,8 @@
 
 #include "MapWidget.h"
 
+namespace Maps {
+
 class UsersMapWidget final : public MapWidget {
     C_OBJECT(UsersMapWidget);
 
@@ -23,7 +25,7 @@ public:
                 add_users_to_map();
             }
         } else {
-            clear_markers();
+            remove_markers_with_name("users"sv);
             remove_panels_with_name("users"sv);
         }
     }
@@ -46,3 +48,5 @@ private:
     };
     Optional<Vector<User>> m_users;
 };
+
+}

+ 54 - 16
Userland/Applications/Maps/main.cpp

@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: BSD-2-Clause
  */
 
+#include "SearchPanel.h"
 #include "UsersMapWidget.h"
 #include <LibConfig/Client.h>
 #include <LibCore/System.h>
@@ -13,6 +14,7 @@
 #include <LibGUI/Icon.h>
 #include <LibGUI/Menu.h>
 #include <LibGUI/Process.h>
+#include <LibGUI/Splitter.h>
 #include <LibGUI/ToolbarContainer.h>
 #include <LibGUI/Window.h>
 
@@ -33,6 +35,7 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
 
     Config::monitor_domain("Maps");
 
+    // Window
     auto app_icon = TRY(GUI::Icon::try_create_default_icon("app-maps"sv));
     auto window = GUI::Window::construct();
     window->set_title("Maps");
@@ -49,14 +52,32 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
     auto toolbar_container = TRY(root_widget->try_add<GUI::ToolbarContainer>());
     auto toolbar = TRY(toolbar_container->try_add<GUI::Toolbar>());
 
+    // Main Widget
+    auto main_widget = TRY(root_widget->try_add<GUI::HorizontalSplitter>());
+
     // Map widget
-    UsersMapWidget::Options options {};
+    Maps::UsersMapWidget::Options options {};
     options.center.latitude = Config::read_string("Maps"sv, "MapView"sv, "CenterLatitude"sv, "30"sv).to_double().value_or(30.0);
     options.center.longitude = Config::read_string("Maps"sv, "MapView"sv, "CenterLongitude"sv, "0"sv).to_double().value_or(0.0);
     options.zoom = Config::read_i32("Maps"sv, "MapView"sv, "Zoom"sv, MAP_ZOOM_DEFAULT);
-    auto maps = TRY(root_widget->try_add<UsersMapWidget>(options));
-    maps->set_frame_style(Gfx::FrameStyle::SunkenContainer);
-    maps->set_show_users(Config::read_bool("Maps"sv, "MapView"sv, "ShowUsers"sv, false));
+    auto map_widget = TRY(main_widget->try_add<Maps::UsersMapWidget>(options));
+    map_widget->set_frame_style(Gfx::FrameStyle::SunkenContainer);
+    map_widget->set_show_users(Config::read_bool("Maps"sv, "MapView"sv, "ShowUsers"sv, false));
+
+    // Search panel
+    auto search_panel = TRY(Maps::SearchPanel::create());
+    search_panel->on_places_change = [map_widget](auto) { map_widget->remove_markers_with_name("search"sv); };
+    search_panel->on_selected_place_change = [map_widget](auto const& place) {
+        // Remove old search markers
+        map_widget->remove_markers_with_name("search"sv);
+
+        // Add new marker and zoom into it
+        map_widget->add_marker({ place.latlng, place.name, {}, "search"_string });
+        map_widget->set_center(place.latlng);
+        map_widget->set_zoom(place.zoom);
+    };
+    if (Config::read_bool("Maps"sv, "SearchPanel"sv, "Show"sv, false))
+        main_widget->insert_child_before(search_panel, map_widget);
 
     // Main menu actions
     auto file_menu = window->add_menu("&File"_string);
@@ -68,18 +89,32 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
     file_menu->add_action(GUI::CommonActions::make_quit_action([](auto&) { GUI::Application::the()->quit(); }));
 
     auto view_menu = window->add_menu("&View"_string);
+    auto show_search_panel_action = GUI::Action::create_checkable(
+        "Show search panel", TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/find.png"sv)), [main_widget, search_panel, map_widget](auto& action) {
+            if (action.is_checked()) {
+                main_widget->insert_child_before(search_panel, map_widget);
+            } else {
+                map_widget->remove_markers_with_name("search"sv);
+                search_panel->reset();
+                main_widget->remove_child(search_panel);
+            }
+        },
+        window);
+    show_search_panel_action->set_checked(Config::read_bool("Maps"sv, "SearchPanel"sv, "Show"sv, false));
     auto show_users_action = GUI::Action::create_checkable(
-        "Show SerenityOS users", TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/ladyball.png"sv)), [maps](auto& action) { maps->set_show_users(action.is_checked()); }, window);
-    show_users_action->set_checked(maps->show_users());
-    auto zoom_in_action = GUI::CommonActions::make_zoom_in_action([maps](auto&) { maps->set_zoom(maps->zoom() + 1); }, window);
-    auto zoom_out_action = GUI::CommonActions::make_zoom_out_action([maps](auto&) { maps->set_zoom(maps->zoom() - 1); }, window);
-    auto reset_zoom_action = GUI::CommonActions::make_reset_zoom_action([maps](auto&) { maps->set_zoom(MAP_ZOOM_DEFAULT); }, window);
-    auto fullscreen_action = GUI::CommonActions::make_fullscreen_action([window, toolbar_container, maps](auto&) {
+        "Show SerenityOS users", TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/ladyball.png"sv)), [map_widget](auto& action) { map_widget->set_show_users(action.is_checked()); }, window);
+    show_users_action->set_checked(map_widget->show_users());
+    auto zoom_in_action = GUI::CommonActions::make_zoom_in_action([map_widget](auto&) { map_widget->set_zoom(map_widget->zoom() + 1); }, window);
+    auto zoom_out_action = GUI::CommonActions::make_zoom_out_action([map_widget](auto&) { map_widget->set_zoom(map_widget->zoom() - 1); }, window);
+    auto reset_zoom_action = GUI::CommonActions::make_reset_zoom_action([map_widget](auto&) { map_widget->set_zoom(MAP_ZOOM_DEFAULT); }, window);
+    auto fullscreen_action = GUI::CommonActions::make_fullscreen_action([window, toolbar_container, map_widget](auto&) {
         window->set_fullscreen(!window->is_fullscreen());
         toolbar_container->set_visible(!window->is_fullscreen());
-        maps->set_frame_style(window->is_fullscreen() ? Gfx::FrameStyle::NoFrame : Gfx::FrameStyle::SunkenContainer);
+        map_widget->set_frame_style(window->is_fullscreen() ? Gfx::FrameStyle::NoFrame : Gfx::FrameStyle::SunkenContainer);
     },
         window);
+    view_menu->add_action(show_search_panel_action);
+    view_menu->add_separator();
     view_menu->add_action(show_users_action);
     view_menu->add_separator();
     view_menu->add_action(zoom_in_action);
@@ -93,6 +128,8 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
     help_menu->add_action(GUI::CommonActions::make_about_action("Maps"_string, app_icon, window));
 
     // Main toolbar actions
+    toolbar->add_action(show_search_panel_action);
+    toolbar->add_separator();
     toolbar->add_action(show_users_action);
     toolbar->add_separator();
     toolbar->add_action(zoom_in_action);
@@ -103,11 +140,12 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
 
     window->show();
 
-    // Remember last map position
+    // Remember last window state
     int exec = app->exec();
-    Config::write_string("Maps"sv, "MapView"sv, "CenterLatitude"sv, TRY(String::number(maps->center().latitude)));
-    Config::write_string("Maps"sv, "MapView"sv, "CenterLongitude"sv, TRY(String::number(maps->center().longitude)));
-    Config::write_i32("Maps"sv, "MapView"sv, "Zoom"sv, maps->zoom());
-    Config::write_bool("Maps"sv, "MapView"sv, "ShowUsers"sv, maps->show_users());
+    Config::write_bool("Maps"sv, "SearchPanel"sv, "Show"sv, show_search_panel_action->is_checked());
+    Config::write_string("Maps"sv, "MapView"sv, "CenterLatitude"sv, TRY(String::number(map_widget->center().latitude)));
+    Config::write_string("Maps"sv, "MapView"sv, "CenterLongitude"sv, TRY(String::number(map_widget->center().longitude)));
+    Config::write_i32("Maps"sv, "MapView"sv, "Zoom"sv, map_widget->zoom());
+    Config::write_bool("Maps"sv, "MapView"sv, "ShowUsers"sv, map_widget->show_users());
     return exec;
 }