diff --git a/Userland/Applications/Maps/CMakeLists.txt b/Userland/Applications/Maps/CMakeLists.txt index 5e3633bc58..1b1587075c 100644 --- a/Userland/Applications/Maps/CMakeLists.txt +++ b/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 ) diff --git a/Userland/Applications/Maps/MapWidget.cpp b/Userland/Applications/Maps/MapWidget.cpp index df4fb38c5a..c5207d6563 100644 --- a/Userland/Applications/Maps/MapWidget.cpp +++ b/Userland/Applications/Maps/MapWidget.cpp @@ -16,6 +16,8 @@ #include #include +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); } + +} diff --git a/Userland/Applications/Maps/MapWidget.h b/Userland/Applications/Maps/MapWidget.h index b931e41175..b92f496c2b 100644 --- a/Userland/Applications/Maps/MapWidget.h +++ b/Userland/Applications/Maps/MapWidget.h @@ -15,6 +15,8 @@ #include #include +namespace Maps { + class MapWidget : public GUI::Frame , public Config::Listener { C_OBJECT(MapWidget); @@ -63,15 +65,16 @@ public: LatLng latlng; Optional tooltip {}; RefPtr image { nullptr }; + Optional 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 m_panels; }; +} + template<> -struct AK::Traits : public GenericTraits { - static unsigned hash(MapWidget::TileKey const& t) { return t.hash(); } +struct AK::Traits : public GenericTraits { + static unsigned hash(Maps::MapWidget::TileKey const& t) { return t.hash(); } }; diff --git a/Userland/Applications/Maps/SearchPanel.cpp b/Userland/Applications/Maps/SearchPanel.cpp new file mode 100644 index 0000000000..338f5a7718 --- /dev/null +++ b/Userland/Applications/Maps/SearchPanel.cpp @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SearchPanel.h" +#include + +namespace Maps { + +ErrorOr> SearchPanel::create() +{ + auto widget = TRY(try_create()); + TRY(widget->setup()); + return widget; +} + +ErrorOr SearchPanel::setup() +{ + m_request_client = TRY(Protocol::RequestClient::try_create()); + + m_search_textbox = *find_descendant_of_type_named("search_textbox"); + m_search_button = *find_descendant_of_type_named("search_button"); + m_start_container = *find_descendant_of_type_named("start_container"); + m_empty_container = *find_descendant_of_type_named("empty_container"); + m_places_list = *find_descendant_of_type_named("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 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::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); +} + +} diff --git a/Userland/Applications/Maps/SearchPanel.gml b/Userland/Applications/Maps/SearchPanel.gml new file mode 100644 index 0000000000..bb6b047189 --- /dev/null +++ b/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 + } +} diff --git a/Userland/Applications/Maps/SearchPanel.h b/Userland/Applications/Maps/SearchPanel.h new file mode 100644 index 0000000000..1d33d8af05 --- /dev/null +++ b/Userland/Applications/Maps/SearchPanel.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "MapWidget.h" +#include +#include +#include +#include +#include +#include +#include + +namespace Maps { + +class SearchPanel final : public GUI::Widget { + C_OBJECT(SearchPanel) + +public: + static ErrorOr> create(); + + void search(StringView query); + void reset(); + + struct Place { + String name; + MapWidget::LatLng latlng; + int zoom; + }; + Function const&)> on_places_change; + Function on_selected_place_change; + +private: + SearchPanel() = default; + static ErrorOr> try_create(); + + ErrorOr setup(); + + RefPtr m_request_client; + RefPtr m_request; + RefPtr m_search_textbox; + RefPtr m_search_button; + RefPtr m_start_container; + RefPtr m_empty_container; + RefPtr m_places_list; + RefPtr> m_places_names_model; + Vector m_places; + Vector m_places_names; +}; + +} diff --git a/Userland/Applications/Maps/UsersMapWidget.cpp b/Userland/Applications/Maps/UsersMapWidget.cpp index 22178366fc..dadb246914 100644 --- a/Userland/Applications/Maps/UsersMapWidget.cpp +++ b/Userland/Applications/Maps/UsersMapWidget.cpp @@ -8,6 +8,8 @@ #include #include +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 }); } + +} diff --git a/Userland/Applications/Maps/UsersMapWidget.h b/Userland/Applications/Maps/UsersMapWidget.h index 931ce6ce45..7d896153af 100644 --- a/Userland/Applications/Maps/UsersMapWidget.h +++ b/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> m_users; }; + +} diff --git a/Userland/Applications/Maps/main.cpp b/Userland/Applications/Maps/main.cpp index 04be7b5ef6..b65cda4b22 100644 --- a/Userland/Applications/Maps/main.cpp +++ b/Userland/Applications/Maps/main.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include "SearchPanel.h" #include "UsersMapWidget.h" #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include @@ -33,6 +35,7 @@ ErrorOr 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 serenity_main(Main::Arguments arguments) auto toolbar_container = TRY(root_widget->try_add()); auto toolbar = TRY(toolbar_container->try_add()); + // Main Widget + auto main_widget = TRY(root_widget->try_add()); + // 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(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(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 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 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 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; }