From 995939bef8e1006d5a4a57c10c2d6fa0ed04ffca Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Tue, 21 Jul 2026 21:37:33 +0200 Subject: [PATCH] feat: add entity details side panel --- include/widgets/entity_details_panel.h | 119 ++ include/widgets/investigation_graph_view.h | 68 + include/widgets/workspace.h | 1 - src/widgets/entity_details_panel.c | 985 +++++++++++++++ src/widgets/investigation_graph_view.c | 1314 +++++++++++++++++--- src/widgets/workspace.c | 164 ++- 6 files changed, 2469 insertions(+), 182 deletions(-) create mode 100644 include/widgets/entity_details_panel.h create mode 100644 src/widgets/entity_details_panel.c diff --git a/include/widgets/entity_details_panel.h b/include/widgets/entity_details_panel.h new file mode 100644 index 0000000..e8efea6 --- /dev/null +++ b/include/widgets/entity_details_panel.h @@ -0,0 +1,119 @@ +/****************************************************************************** + * @file entity_details_panel.h + * @brief Volet latéral affichant les détails d'une entité. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H +#define LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H + +#include + +G_BEGIN_DECLS + +/** + * @brief Modèle opaque représentant une entité. + */ +typedef struct EntityRecord EntityRecord; + +/** + * @brief Représentation opaque du volet de détails. + */ +typedef struct EntityDetailsPanel EntityDetailsPanel; + +/** + * @brief Callback appelé lorsque l'utilisateur ferme le volet. + * + * Le callback n'est pas déclenché lors d'une fermeture programmatique avec + * entity_details_panel_clear(). + * + * @param user_data Données empruntées fournies par l'appelant. + */ +typedef void (*EntityDetailsPanelCloseCallback)( + gpointer user_data +); + +/** + * @brief Crée un volet de détails fermé. + * + * @return Nouveau volet, ou NULL en cas d'échec. + */ +EntityDetailsPanel *entity_details_panel_new(void); + +/** + * @brief Retourne le widget GTK racine du volet. + * + * Le widget appartient au module EntityDetailsPanel. + * + * @param details_panel Volet à consulter. + * + * @return Widget GTK racine, ou NULL. + */ +GtkWidget *entity_details_panel_get_widget( + const EntityDetailsPanel *details_panel +); + +/** + * @brief Affiche une entité et ouvre le volet. + * + * Les textes sont immédiatement copiés dans les widgets GTK. + * Le pointeur EntityRecord n'est jamais conservé. + * + * Passer NULL équivaut à entity_details_panel_clear(). + * + * @param details_panel Volet à mettre à jour. + * @param entity_record Entité empruntée, ou NULL. + */ +void entity_details_panel_set_entity( + EntityDetailsPanel *details_panel, + const EntityRecord *entity_record +); + +/** + * @brief Vide et referme le volet sans appeler le callback de fermeture. + * + * @param details_panel Volet à vider. + */ +void entity_details_panel_clear( + EntityDetailsPanel *details_panel +); + +/** + * @brief Définit le callback du bouton de fermeture. + * + * Le callback et user_data sont empruntés. + * + * @param details_panel Volet à configurer. + * @param callback Callback à appeler, ou NULL. + * @param user_data Données empruntées du callback. + */ +void entity_details_panel_set_close_callback( + EntityDetailsPanel *details_panel, + EntityDetailsPanelCloseCallback callback, + gpointer user_data +); + +/** + * @brief Indique si le volet est actuellement ouvert. + * + * @param details_panel Volet à consulter. + * + * @return TRUE si le volet est ouvert, sinon FALSE. + */ +gboolean entity_details_panel_is_open( + const EntityDetailsPanel *details_panel +); + +/** + * @brief Libère la structure d'encapsulation du volet. + * + * Cette fonction accepte NULL. + * + * @param details_panel Volet à libérer. + */ +void entity_details_panel_free( + EntityDetailsPanel *details_panel +); + +G_END_DECLS + +#endif diff --git a/include/widgets/investigation_graph_view.h b/include/widgets/investigation_graph_view.h index 5adc0f7..d5a8bc3 100644 --- a/include/widgets/investigation_graph_view.h +++ b/include/widgets/investigation_graph_view.h @@ -10,6 +10,11 @@ G_BEGIN_DECLS +/** + * @brief Représentation opaque d'une entité métier. + */ +typedef struct EntityRecord EntityRecord; + /** * @brief Représentation opaque du graphe métier d'une enquête. */ @@ -20,6 +25,19 @@ typedef struct InvestigationGraphModel InvestigationGraphModel; */ typedef struct InvestigationGraphView InvestigationGraphView; +/** + * @brief Callback appelé lors d'un changement de sélection. + * + * entity_record est emprunté au graphe. NULL représente une désélection. + * + * @param entity_record Entité sélectionnée empruntée, ou NULL. + * @param user_data Données empruntées fournies par l'appelant. + */ +typedef void (*InvestigationGraphViewSelectionCallback)( + const EntityRecord *entity_record, + gpointer user_data +); + /** * @brief Crée une nouvelle vue graphique vide. * @@ -67,6 +85,45 @@ void investigation_graph_view_clear( InvestigationGraphView *graph_view ); +/** + * @brief Définit le callback de changement de sélection. + * + * Le callback et user_data sont empruntés par la vue. + * + * @param graph_view Vue graphique. + * @param callback Callback à appeler, ou NULL. + * @param user_data Données empruntées du callback. + */ +void investigation_graph_view_set_selection_callback( + InvestigationGraphView *graph_view, + InvestigationGraphViewSelectionCallback callback, + gpointer user_data +); + +/** + * @brief Retourne l'entité actuellement sélectionnée. + * + * Le pointeur retourné est emprunté au graphe. + * + * @param graph_view Vue graphique à consulter. + * + * @return Entité sélectionnée empruntée, ou NULL. + */ +const EntityRecord *investigation_graph_view_get_selected_entity( + const InvestigationGraphView *graph_view +); + +/** + * @brief Supprime la sélection courante. + * + * Le callback reçoit NULL seulement si une sélection existait. + * + * @param graph_view Vue graphique. + */ +void investigation_graph_view_clear_selection( + InvestigationGraphView *graph_view +); + /** * @brief Réinitialise le zoom et la position du canvas. * @@ -78,6 +135,17 @@ void investigation_graph_view_reset_view( InvestigationGraphView *graph_view ); +/** + * @brief Restaure la disposition déterministe initiale des nœuds. + * + * Le zoom et le déplacement global du canvas sont conservés. + * + * @param graph_view Vue graphique à réorganiser. + */ +void investigation_graph_view_reset_layout( + InvestigationGraphView *graph_view +); + /** * @brief Libère la structure d'encapsulation de la vue. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 17ddbd3..5768228 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -158,4 +158,3 @@ void workspace_free( ); #endif - diff --git a/src/widgets/entity_details_panel.c b/src/widgets/entity_details_panel.c new file mode 100644 index 0000000..cf7608a --- /dev/null +++ b/src/widgets/entity_details_panel.c @@ -0,0 +1,985 @@ +/****************************************************************************** + * @file entity_details_panel.c + * @brief Implémentation du volet latéral de détails d'une entité. + ******************************************************************************/ + +#include "widgets/entity_details_panel.h" + +#include "models/entity_record.h" + +#include + +/** + * @struct EntityDetailsPanel + * @brief État privé du volet latéral. + */ +struct EntityDetailsPanel +{ + GtkWidget *root_revealer; + GtkWidget *frame; + GtkWidget *close_button; + + GtkWidget *entity_title_label; + GtkWidget *entity_value_label; + GtkWidget *entity_type_label; + GtkWidget *entity_description_label; + GtkWidget *entity_confidence_label; + GtkWidget *entity_status_label; + GtkWidget *entity_created_at_label; + GtkWidget *entity_updated_at_label; + GtkWidget *entity_identifier_label; + + EntityDetailsPanelCloseCallback close_callback; + gpointer close_user_data; +}; + +/** + * @brief Définit une valeur avec un texte de remplacement. + */ +static void entity_details_panel_set_field_text( + GtkWidget *label, + const char *value, + const char *fallback +) +{ + if (label == NULL) + { + return; + } + + gtk_label_set_text( + GTK_LABEL(label), + value != NULL && + value[0] != '\0' + ? value + : fallback + ); +} + +/** + * @brief Ajoute une ligne de métadonnée dans la grille. + * + * @return Label GTK destiné à recevoir la valeur. + */ +static GtkWidget *entity_details_panel_add_field( + GtkGrid *grid, + gint row, + const char *title +) +{ + GtkWidget *title_label = + NULL; + + GtkWidget *value_label = + NULL; + + if (grid == NULL || + title == NULL || + title[0] == '\0') + { + return NULL; + } + + title_label = + gtk_label_new( + title + ); + + value_label = + gtk_label_new( + NULL + ); + + if (title_label == NULL || + value_label == NULL) + { + return NULL; + } + + gtk_label_set_xalign( + GTK_LABEL(title_label), + 0.0F + ); + + gtk_widget_set_valign( + title_label, + GTK_ALIGN_START + ); + + gtk_widget_add_css_class( + title_label, + "dim-label" + ); + + gtk_label_set_xalign( + GTK_LABEL(value_label), + 0.0F + ); + + gtk_label_set_wrap( + GTK_LABEL(value_label), + TRUE + ); + + gtk_label_set_wrap_mode( + GTK_LABEL(value_label), + PANGO_WRAP_WORD_CHAR + ); + + gtk_label_set_selectable( + GTK_LABEL(value_label), + TRUE + ); + + gtk_widget_set_hexpand( + value_label, + TRUE + ); + + gtk_widget_set_halign( + value_label, + GTK_ALIGN_FILL + ); + + gtk_widget_set_valign( + value_label, + GTK_ALIGN_START + ); + + gtk_grid_attach( + grid, + title_label, + 0, + row, + 1, + 1 + ); + + gtk_grid_attach( + grid, + value_label, + 1, + row, + 1, + 1 + ); + + return value_label; +} + +/** + * @brief Convertit le statut métier en texte utilisateur. + */ +static const char *entity_details_panel_get_status_text( + EntityStatus status +) +{ + switch (status) + { + case ENTITY_STATUS_ACTIVE: + return "Active"; + + case ENTITY_STATUS_ARCHIVED: + return "Archivée"; + + case ENTITY_STATUS_DELETED: + return "Supprimée"; + + case ENTITY_STATUS_UNKNOWN: + default: + return "Inconnu"; + } +} + +/** + * @brief Ferme le volet depuis le bouton de l'interface. + */ +static void entity_details_panel_on_close_clicked( + GtkButton *button, + gpointer user_data +) +{ + EntityDetailsPanel *details_panel = + user_data; + + (void) button; + + if (details_panel == NULL) + { + return; + } + + entity_details_panel_clear( + details_panel + ); + + if (details_panel->close_callback != NULL) + { + details_panel->close_callback( + details_panel->close_user_data + ); + } +} + +EntityDetailsPanel *entity_details_panel_new(void) +{ + EntityDetailsPanel *details_panel = + NULL; + + GtkWidget *content_box = + NULL; + + GtkWidget *header_box = + NULL; + + GtkWidget *header_label = + NULL; + + GtkWidget *separator = + NULL; + + GtkWidget *scrolled_window = + NULL; + + GtkWidget *details_box = + NULL; + + GtkWidget *details_grid = + NULL; + + details_panel = + g_try_new0( + EntityDetailsPanel, + 1 + ); + + if (details_panel == NULL) + { + return NULL; + } + + details_panel->root_revealer = + gtk_revealer_new(); + + details_panel->frame = + gtk_frame_new( + NULL + ); + + details_panel->close_button = + gtk_button_new_from_icon_name( + "window-close-symbolic" + ); + + content_box = + gtk_box_new( + GTK_ORIENTATION_VERTICAL, + 0 + ); + + header_box = + gtk_box_new( + GTK_ORIENTATION_HORIZONTAL, + 8 + ); + + header_label = + gtk_label_new( + "Détails de l’entité" + ); + + separator = + gtk_separator_new( + GTK_ORIENTATION_HORIZONTAL + ); + + scrolled_window = + gtk_scrolled_window_new(); + + details_box = + gtk_box_new( + GTK_ORIENTATION_VERTICAL, + 16 + ); + + details_grid = + gtk_grid_new(); + + if (details_panel->root_revealer == NULL || + details_panel->frame == NULL || + details_panel->close_button == NULL || + content_box == NULL || + header_box == NULL || + header_label == NULL || + separator == NULL || + scrolled_window == NULL || + details_box == NULL || + details_grid == NULL) + { + entity_details_panel_free( + details_panel + ); + + return NULL; + } + + gtk_widget_set_size_request( + details_panel->root_revealer, + 360, + -1 + ); + + gtk_widget_set_halign( + details_panel->root_revealer, + GTK_ALIGN_END + ); + + gtk_widget_set_valign( + details_panel->root_revealer, + GTK_ALIGN_FILL + ); + + gtk_widget_set_vexpand( + details_panel->root_revealer, + TRUE + ); + + gtk_revealer_set_transition_type( + GTK_REVEALER( + details_panel->root_revealer + ), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT + ); + + gtk_revealer_set_transition_duration( + GTK_REVEALER( + details_panel->root_revealer + ), + 180 + ); + + gtk_revealer_set_reveal_child( + GTK_REVEALER( + details_panel->root_revealer + ), + FALSE + ); + + gtk_widget_set_margin_top( + details_panel->frame, + 12 + ); + + gtk_widget_set_margin_end( + details_panel->frame, + 12 + ); + + gtk_widget_set_margin_bottom( + details_panel->frame, + 12 + ); + + gtk_widget_add_css_class( + details_panel->frame, + "view" + ); + + gtk_widget_set_margin_start( + header_box, + 16 + ); + + gtk_widget_set_margin_end( + header_box, + 8 + ); + + gtk_widget_set_margin_top( + header_box, + 8 + ); + + gtk_widget_set_margin_bottom( + header_box, + 8 + ); + + gtk_label_set_xalign( + GTK_LABEL(header_label), + 0.0F + ); + + gtk_widget_set_hexpand( + header_label, + TRUE + ); + + gtk_widget_set_halign( + header_label, + GTK_ALIGN_FILL + ); + + gtk_widget_add_css_class( + header_label, + "heading" + ); + + gtk_widget_set_tooltip_text( + details_panel->close_button, + "Fermer le volet" + ); + + gtk_widget_add_css_class( + details_panel->close_button, + "flat" + ); + + gtk_box_append( + GTK_BOX(header_box), + header_label + ); + + gtk_box_append( + GTK_BOX(header_box), + details_panel->close_button + ); + + gtk_box_append( + GTK_BOX(content_box), + header_box + ); + + gtk_box_append( + GTK_BOX(content_box), + separator + ); + + gtk_scrolled_window_set_policy( + GTK_SCROLLED_WINDOW(scrolled_window), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC + ); + + gtk_widget_set_vexpand( + scrolled_window, + TRUE + ); + + gtk_widget_set_margin_start( + details_box, + 20 + ); + + gtk_widget_set_margin_end( + details_box, + 20 + ); + + gtk_widget_set_margin_top( + details_box, + 20 + ); + + gtk_widget_set_margin_bottom( + details_box, + 20 + ); + + details_panel->entity_title_label = + gtk_label_new( + NULL + ); + + if (details_panel->entity_title_label == NULL) + { + entity_details_panel_free( + details_panel + ); + + return NULL; + } + + gtk_label_set_xalign( + GTK_LABEL( + details_panel->entity_title_label + ), + 0.0F + ); + + gtk_label_set_wrap( + GTK_LABEL( + details_panel->entity_title_label + ), + TRUE + ); + + gtk_label_set_selectable( + GTK_LABEL( + details_panel->entity_title_label + ), + TRUE + ); + + gtk_widget_add_css_class( + details_panel->entity_title_label, + "title-3" + ); + + gtk_box_append( + GTK_BOX(details_box), + details_panel->entity_title_label + ); + + gtk_grid_set_column_spacing( + GTK_GRID(details_grid), + 16 + ); + + gtk_grid_set_row_spacing( + GTK_GRID(details_grid), + 12 + ); + + gtk_widget_set_hexpand( + details_grid, + TRUE + ); + + details_panel->entity_value_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 0, + "Valeur" + ); + + details_panel->entity_type_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 1, + "Type" + ); + + details_panel->entity_description_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 2, + "Description" + ); + + details_panel->entity_confidence_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 3, + "Confiance" + ); + + details_panel->entity_status_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 4, + "Statut" + ); + + details_panel->entity_created_at_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 5, + "Créée le" + ); + + details_panel->entity_updated_at_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 6, + "Modifiée le" + ); + + details_panel->entity_identifier_label = + entity_details_panel_add_field( + GTK_GRID(details_grid), + 7, + "Identifiant" + ); + + if (details_panel->entity_value_label == NULL || + details_panel->entity_type_label == NULL || + details_panel->entity_description_label == NULL || + details_panel->entity_confidence_label == NULL || + details_panel->entity_status_label == NULL || + details_panel->entity_created_at_label == NULL || + details_panel->entity_updated_at_label == NULL || + details_panel->entity_identifier_label == NULL) + { + entity_details_panel_free( + details_panel + ); + + return NULL; + } + + gtk_box_append( + GTK_BOX(details_box), + details_grid + ); + + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(scrolled_window), + details_box + ); + + gtk_box_append( + GTK_BOX(content_box), + scrolled_window + ); + + gtk_frame_set_child( + GTK_FRAME(details_panel->frame), + content_box + ); + + gtk_revealer_set_child( + GTK_REVEALER( + details_panel->root_revealer + ), + details_panel->frame + ); + + g_signal_connect( + details_panel->close_button, + "clicked", + G_CALLBACK( + entity_details_panel_on_close_clicked + ), + details_panel + ); + + entity_details_panel_clear( + details_panel + ); + + return details_panel; +} + +GtkWidget *entity_details_panel_get_widget( + const EntityDetailsPanel *details_panel +) +{ + if (details_panel == NULL) + { + return NULL; + } + + return details_panel->root_revealer; +} + +void entity_details_panel_set_entity( + EntityDetailsPanel *details_panel, + const EntityRecord *entity_record +) +{ + const char *title = + NULL; + + char *confidence_text = + NULL; + + if (details_panel == NULL) + { + return; + } + + if (entity_record == NULL) + { + entity_details_panel_clear( + details_panel + ); + + return; + } + + title = + entity_record_get_label( + entity_record + ); + + if (title == NULL || + title[0] == '\0') + { + title = + entity_record_get_value( + entity_record + ); + } + + if (title == NULL || + title[0] == '\0') + { + title = + entity_record_get_identifier( + entity_record + ); + } + + confidence_text = + g_strdup_printf( + "%d %%", + entity_record_get_confidence( + entity_record + ) + ); + + entity_details_panel_set_field_text( + details_panel->entity_title_label, + title, + "(entité sans libellé)" + ); + + entity_details_panel_set_field_text( + details_panel->entity_value_label, + entity_record_get_value( + entity_record + ), + "(valeur inconnue)" + ); + + entity_details_panel_set_field_text( + details_panel->entity_type_label, + entity_record_get_type_identifier( + entity_record + ), + "(type inconnu)" + ); + + entity_details_panel_set_field_text( + details_panel->entity_description_label, + entity_record_get_description( + entity_record + ), + "Non renseignée" + ); + + entity_details_panel_set_field_text( + details_panel->entity_confidence_label, + confidence_text, + "Inconnue" + ); + + entity_details_panel_set_field_text( + details_panel->entity_status_label, + entity_details_panel_get_status_text( + entity_record_get_status( + entity_record + ) + ), + "Inconnu" + ); + + entity_details_panel_set_field_text( + details_panel->entity_created_at_label, + entity_record_get_created_at( + entity_record + ), + "(date inconnue)" + ); + + entity_details_panel_set_field_text( + details_panel->entity_updated_at_label, + entity_record_get_updated_at( + entity_record + ), + "(date inconnue)" + ); + + entity_details_panel_set_field_text( + details_panel->entity_identifier_label, + entity_record_get_identifier( + entity_record + ), + "(identifiant inconnu)" + ); + + gtk_revealer_set_reveal_child( + GTK_REVEALER( + details_panel->root_revealer + ), + TRUE + ); + + g_free( + confidence_text + ); +} + +void entity_details_panel_clear( + EntityDetailsPanel *details_panel +) +{ + if (details_panel == NULL) + { + return; + } + + entity_details_panel_set_field_text( + details_panel->entity_title_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_value_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_type_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_description_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_confidence_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_status_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_created_at_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_updated_at_label, + NULL, + "" + ); + + entity_details_panel_set_field_text( + details_panel->entity_identifier_label, + NULL, + "" + ); + + if (details_panel->root_revealer != NULL) + { + gtk_revealer_set_reveal_child( + GTK_REVEALER( + details_panel->root_revealer + ), + FALSE + ); + } +} + +void entity_details_panel_set_close_callback( + EntityDetailsPanel *details_panel, + EntityDetailsPanelCloseCallback callback, + gpointer user_data +) +{ + if (details_panel == NULL) + { + return; + } + + details_panel->close_callback = + callback; + + details_panel->close_user_data = + user_data; +} + +gboolean entity_details_panel_is_open( + const EntityDetailsPanel *details_panel +) +{ + if (details_panel == NULL || + details_panel->root_revealer == NULL) + { + return FALSE; + } + + return gtk_revealer_get_reveal_child( + GTK_REVEALER( + details_panel->root_revealer + ) + ); +} + +void entity_details_panel_free( + EntityDetailsPanel *details_panel +) +{ + if (details_panel == NULL) + { + return; + } + + if (details_panel->close_button != NULL) + { + g_signal_handlers_disconnect_by_data( + details_panel->close_button, + details_panel + ); + } + + details_panel->close_callback = + NULL; + + details_panel->close_user_data = + NULL; + + details_panel->root_revealer = + NULL; + + details_panel->frame = + NULL; + + details_panel->close_button = + NULL; + + details_panel->entity_title_label = + NULL; + + details_panel->entity_value_label = + NULL; + + details_panel->entity_type_label = + NULL; + + details_panel->entity_description_label = + NULL; + + details_panel->entity_confidence_label = + NULL; + + details_panel->entity_status_label = + NULL; + + details_panel->entity_created_at_label = + NULL; + + details_panel->entity_updated_at_label = + NULL; + + details_panel->entity_identifier_label = + NULL; + + g_free( + details_panel + ); +} diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index 6451fbb..f4d0811 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -28,6 +28,7 @@ #define INVESTIGATION_GRAPH_VIEW_SCROLL_ZOOM_SPEED 0.08 #define INVESTIGATION_GRAPH_VIEW_MIN_SCROLL_FACTOR 0.50 #define INVESTIGATION_GRAPH_VIEW_MAX_SCROLL_FACTOR 1.50 +#define INVESTIGATION_GRAPH_VIEW_CLICK_TOLERANCE 4.0 /** * @brief Position privée d'une entité dans la vue. @@ -50,12 +51,21 @@ struct InvestigationGraphView GtkGesture *zoom_gesture; GtkGesture *drag_gesture; + GtkGesture *click_gesture; GtkEventController *scroll_controller; GtkEventController *motion_controller; const InvestigationGraphModel *graph_model; + GPtrArray *node_layouts; + GHashTable *node_layouts_by_identifier; + + GPtrArray *relations; + GHashTable *relation_pair_counts; + + char *layout_error_message; + double zoom; double offset_x; double offset_y; @@ -75,8 +85,20 @@ struct InvestigationGraphView double drag_origin_offset_x; double drag_origin_offset_y; + InvestigationGraphNodeLayout *dragged_node; + InvestigationGraphNodeLayout *selected_node; + InvestigationGraphNodeLayout *click_candidate_node; + + InvestigationGraphViewSelectionCallback selection_callback; + gpointer selection_user_data; + + double node_drag_pointer_offset_x; + double node_drag_pointer_offset_y; + gboolean pointer_position_valid; gboolean dragging; + gboolean node_dragging; + gboolean click_suppressed; }; /** @@ -468,7 +490,326 @@ static gboolean investigation_graph_view_on_scroll( } /** - * @brief Commence le déplacement de l'ensemble du canvas. + * @brief Convertit une position écran en coordonnées logiques du graphe. + */ +static gboolean investigation_graph_view_screen_to_logical( + const InvestigationGraphView *graph_view, + double screen_x, + double screen_y, + double *logical_x, + double *logical_y +) +{ + if (graph_view == NULL || + logical_x == NULL || + logical_y == NULL || + graph_view->zoom <= 0.0) + { + return FALSE; + } + + *logical_x = + ( + screen_x - + graph_view->offset_x + ) / + graph_view->zoom; + + *logical_y = + ( + screen_y - + graph_view->offset_y + ) / + graph_view->zoom; + + return TRUE; +} + +/** + * @brief Recherche le nœud situé sous une position logique. + * + * La recherche est effectuée depuis la fin du tableau afin de privilégier + * les nœuds dessinés au premier plan. + */ +static InvestigationGraphNodeLayout * +investigation_graph_view_find_node_at( + InvestigationGraphView *graph_view, + double logical_x, + double logical_y +) +{ + guint layout_position = + 0; + + if (graph_view == NULL || + graph_view->node_layouts == NULL) + { + return NULL; + } + + layout_position = + graph_view->node_layouts->len; + + while (layout_position > 0) + { + InvestigationGraphNodeLayout *node_layout = + NULL; + + layout_position--; + + node_layout = + g_ptr_array_index( + graph_view->node_layouts, + layout_position + ); + + if (node_layout == NULL) + { + continue; + } + + if (logical_x >= node_layout->x && + logical_x <= + node_layout->x + + INVESTIGATION_GRAPH_VIEW_NODE_WIDTH && + logical_y >= node_layout->y && + logical_y <= + node_layout->y + + INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT) + { + return node_layout; + } + } + + return NULL; +} + +/** + * @brief Modifie la sélection et prévient l'appelant si elle change. + */ +static void investigation_graph_view_set_selected_node( + InvestigationGraphView *graph_view, + InvestigationGraphNodeLayout *node_layout +) +{ + const EntityRecord *entity_record = + NULL; + + if (graph_view == NULL || + graph_view->selected_node == node_layout) + { + return; + } + + graph_view->selected_node = + node_layout; + + if (node_layout != NULL) + { + entity_record = + node_layout->entity_record; + } + + if (graph_view->drawing_area != NULL) + { + gtk_widget_queue_draw( + graph_view->drawing_area + ); + } + + if (graph_view->selection_callback != NULL) + { + graph_view->selection_callback( + entity_record, + graph_view->selection_user_data + ); + } +} + +/** + * @brief Mémorise le nœud présent sous le début d'un clic. + */ +static void investigation_graph_view_on_click_pressed( + GtkGestureClick *gesture, + int press_count, + double x, + double y, + gpointer user_data +) +{ + InvestigationGraphView *graph_view = + user_data; + + GdkModifierType modifier_state = + 0; + + double logical_x = + 0.0; + + double logical_y = + 0.0; + + if (graph_view == NULL || + press_count != 1) + { + return; + } + + graph_view->click_suppressed = + FALSE; + + graph_view->click_candidate_node = + NULL; + + modifier_state = + gtk_event_controller_get_current_event_state( + GTK_EVENT_CONTROLLER( + gesture + ) + ); + + if ((modifier_state & GDK_SHIFT_MASK) != 0) + { + return; + } + + if (!investigation_graph_view_screen_to_logical( + graph_view, + x, + y, + &logical_x, + &logical_y + )) + { + return; + } + + graph_view->click_candidate_node = + investigation_graph_view_find_node_at( + graph_view, + logical_x, + logical_y + ); +} + +/** + * @brief Sélectionne le nœud cliqué ou désélectionne dans le vide. + */ +static void investigation_graph_view_on_click_released( + GtkGestureClick *gesture, + int press_count, + double x, + double y, + gpointer user_data +) +{ + InvestigationGraphView *graph_view = + user_data; + + GdkModifierType modifier_state = + 0; + + InvestigationGraphNodeLayout *released_node = + NULL; + + double logical_x = + 0.0; + + double logical_y = + 0.0; + + if (graph_view == NULL || + press_count != 1) + { + return; + } + + modifier_state = + gtk_event_controller_get_current_event_state( + GTK_EVENT_CONTROLLER( + gesture + ) + ); + + if (graph_view->click_suppressed || + (modifier_state & GDK_SHIFT_MASK) != 0) + { + graph_view->click_suppressed = + FALSE; + + graph_view->click_candidate_node = + NULL; + + return; + } + + if (investigation_graph_view_screen_to_logical( + graph_view, + x, + y, + &logical_x, + &logical_y + )) + { + released_node = + investigation_graph_view_find_node_at( + graph_view, + logical_x, + logical_y + ); + } + + if (released_node == + graph_view->click_candidate_node) + { + investigation_graph_view_set_selected_node( + graph_view, + released_node + ); + } + + graph_view->click_candidate_node = + NULL; +} + +/** + * @brief Annule les états de déplacement sans modifier les positions. + */ +static void investigation_graph_view_cancel_drag( + InvestigationGraphView *graph_view +) +{ + if (graph_view == NULL) + { + return; + } + + graph_view->dragged_node = + NULL; + + graph_view->node_drag_pointer_offset_x = + 0.0; + + graph_view->node_drag_pointer_offset_y = + 0.0; + + graph_view->dragging = + FALSE; + + graph_view->node_dragging = + FALSE; + + if (graph_view->drawing_area != NULL) + { + gtk_widget_set_cursor_from_name( + graph_view->drawing_area, + "default" + ); + } +} + +/** + * @brief Commence un déplacement du canvas ou d'un nœud. */ static void investigation_graph_view_on_drag_begin( GtkGestureDrag *gesture, @@ -483,12 +824,25 @@ static void investigation_graph_view_on_drag_begin( GdkModifierType modifier_state = 0; + double logical_x = + 0.0; + + double logical_y = + 0.0; + + InvestigationGraphNodeLayout *node_layout = + NULL; + if (graph_view == NULL || graph_view->drawing_area == NULL) { return; } + investigation_graph_view_cancel_drag( + graph_view + ); + modifier_state = gtk_event_controller_get_current_event_state( GTK_EVENT_CONTROLLER( @@ -496,21 +850,81 @@ static void investigation_graph_view_on_drag_begin( ) ); - if ((modifier_state & GDK_SHIFT_MASK) == 0) + graph_view->drag_start_x = + start_x; + + graph_view->drag_start_y = + start_y; + + if ((modifier_state & GDK_SHIFT_MASK) != 0) { + graph_view->click_suppressed = + TRUE; + + graph_view->click_candidate_node = + NULL; + + graph_view->drag_origin_offset_x = + graph_view->offset_x; + + graph_view->drag_origin_offset_y = + graph_view->offset_y; + graph_view->dragging = - FALSE; + TRUE; gtk_gesture_set_state( GTK_GESTURE( gesture ), - GTK_EVENT_SEQUENCE_DENIED + GTK_EVENT_SEQUENCE_CLAIMED + ); + + gtk_widget_set_cursor_from_name( + graph_view->drawing_area, + "grabbing" ); return; } + if (!investigation_graph_view_screen_to_logical( + graph_view, + start_x, + start_y, + &logical_x, + &logical_y + )) + { + return; + } + + node_layout = + investigation_graph_view_find_node_at( + graph_view, + logical_x, + logical_y + ); + + if (node_layout == NULL) + { + return; + } + + graph_view->dragged_node = + node_layout; + + graph_view->node_drag_pointer_offset_x = + logical_x - + node_layout->x; + + graph_view->node_drag_pointer_offset_y = + logical_y - + node_layout->y; + + graph_view->node_dragging = + TRUE; + gtk_gesture_set_state( GTK_GESTURE( gesture @@ -518,21 +932,6 @@ static void investigation_graph_view_on_drag_begin( GTK_EVENT_SEQUENCE_CLAIMED ); - graph_view->drag_start_x = - start_x; - - graph_view->drag_start_y = - start_y; - - graph_view->drag_origin_offset_x = - graph_view->offset_x; - - graph_view->drag_origin_offset_y = - graph_view->offset_y; - - graph_view->dragging = - TRUE; - gtk_widget_set_cursor_from_name( graph_view->drawing_area, "grabbing" @@ -540,7 +939,7 @@ static void investigation_graph_view_on_drag_begin( } /** - * @brief Déplace le canvas selon le décalage cumulé du geste. + * @brief Met à jour le déplacement actif. */ static void investigation_graph_view_on_drag_update( GtkGestureDrag *gesture, @@ -552,30 +951,99 @@ static void investigation_graph_view_on_drag_update( InvestigationGraphView *graph_view = user_data; + double pointer_x = + 0.0; + + double pointer_y = + 0.0; + + double logical_x = + 0.0; + + double logical_y = + 0.0; + (void) gesture; if (graph_view == NULL || - graph_view->drawing_area == NULL || - !graph_view->dragging) + graph_view->drawing_area == NULL) { return; } - graph_view->offset_x = - graph_view->drag_origin_offset_x + + if ( + (offset_x * offset_x) + + (offset_y * offset_y) > + ( + INVESTIGATION_GRAPH_VIEW_CLICK_TOLERANCE * + INVESTIGATION_GRAPH_VIEW_CLICK_TOLERANCE + ) + ) + { + graph_view->click_suppressed = + TRUE; + + graph_view->click_candidate_node = + NULL; + } + + if (graph_view->dragging) + { + graph_view->offset_x = + graph_view->drag_origin_offset_x + + offset_x; + + graph_view->offset_y = + graph_view->drag_origin_offset_y + + offset_y; + + gtk_widget_queue_draw( + graph_view->drawing_area + ); + + return; + } + + if (!graph_view->node_dragging || + graph_view->dragged_node == NULL) + { + return; + } + + pointer_x = + graph_view->drag_start_x + offset_x; - graph_view->offset_y = - graph_view->drag_origin_offset_y + + pointer_y = + graph_view->drag_start_y + offset_y; + if (!investigation_graph_view_screen_to_logical( + graph_view, + pointer_x, + pointer_y, + &logical_x, + &logical_y + )) + { + return; + } + + graph_view->dragged_node->x = + logical_x - + graph_view->node_drag_pointer_offset_x; + + graph_view->dragged_node->y = + logical_y - + graph_view->node_drag_pointer_offset_y; + gtk_widget_queue_draw( graph_view->drawing_area ); } /** - * @brief Termine le déplacement du canvas. + * @brief Termine le déplacement actif. */ static void investigation_graph_view_on_drag_end( GtkGestureDrag *gesture, @@ -587,6 +1055,18 @@ static void investigation_graph_view_on_drag_end( InvestigationGraphView *graph_view = user_data; + double pointer_x = + 0.0; + + double pointer_y = + 0.0; + + double logical_x = + 0.0; + + double logical_y = + 0.0; + (void) gesture; if (graph_view == NULL || @@ -605,13 +1085,37 @@ static void investigation_graph_view_on_drag_end( graph_view->drag_origin_offset_y + offset_y; } + else if (graph_view->node_dragging && + graph_view->dragged_node != NULL) + { + pointer_x = + graph_view->drag_start_x + + offset_x; - graph_view->dragging = - FALSE; + pointer_y = + graph_view->drag_start_y + + offset_y; - gtk_widget_set_cursor_from_name( - graph_view->drawing_area, - "default" + if (investigation_graph_view_screen_to_logical( + graph_view, + pointer_x, + pointer_y, + &logical_x, + &logical_y + )) + { + graph_view->dragged_node->x = + logical_x - + graph_view->node_drag_pointer_offset_x; + + graph_view->dragged_node->y = + logical_y - + graph_view->node_drag_pointer_offset_y; + } + } + + investigation_graph_view_cancel_drag( + graph_view ); gtk_widget_queue_draw( @@ -944,6 +1448,277 @@ static GHashTable *investigation_graph_view_create_layout_index( return layout_index; } +/** + * @brief Compte les relations actives par couple source/cible. + */ +static GHashTable *investigation_graph_view_count_relation_pairs( + const GPtrArray *relations, + GHashTable *layout_index +); + +/** + * @brief Supprime la disposition privée actuellement conservée. + * + * Les EntityRecord et RelationRecord contenus dans les collections restent + * la propriété du graphe métier. + */ +static void investigation_graph_view_clear_layout( + InvestigationGraphView *graph_view +) +{ + if (graph_view == NULL) + { + return; + } + + investigation_graph_view_cancel_drag( + graph_view + ); + + investigation_graph_view_set_selected_node( + graph_view, + NULL + ); + + graph_view->click_candidate_node = + NULL; + + graph_view->click_suppressed = + FALSE; + + g_clear_pointer( + &graph_view->relation_pair_counts, + g_hash_table_unref + ); + + g_clear_pointer( + &graph_view->relations, + g_ptr_array_unref + ); + + g_clear_pointer( + &graph_view->node_layouts_by_identifier, + g_hash_table_unref + ); + + g_clear_pointer( + &graph_view->node_layouts, + g_ptr_array_unref + ); + + g_clear_pointer( + &graph_view->layout_error_message, + g_free + ); +} + +/** + * @brief Enregistre une erreur de préparation lorsqu'aucune n'existe. + */ +static void investigation_graph_view_set_layout_error( + GError **error, + const char *message +) +{ + if (error == NULL || + *error != NULL) + { + return; + } + + g_set_error_literal( + error, + INVESTIGATION_GRAPH_MODEL_ERROR, + INVESTIGATION_GRAPH_MODEL_ERROR_MEMORY, + message + ); +} + +/** + * @brief Construit et conserve la disposition du graphe courant. + */ +static gboolean investigation_graph_view_build_layout( + InvestigationGraphView *graph_view, + int width, + GError **error +) +{ + GPtrArray *active_entities = + NULL; + + GPtrArray *node_layouts = + NULL; + + GHashTable *node_layouts_by_identifier = + NULL; + + GPtrArray *relations = + NULL; + + GHashTable *relation_pair_counts = + NULL; + + gboolean success = + FALSE; + + g_return_val_if_fail( + error == NULL || *error == NULL, + FALSE + ); + + if (graph_view == NULL || + graph_view->graph_model == NULL) + { + investigation_graph_view_set_layout_error( + error, + "Le graphe à disposer est absent." + ); + + return FALSE; + } + + if (width <= 0) + { + width = + 640; + } + + active_entities = + investigation_graph_view_list_active_entities( + graph_view->graph_model, + error + ); + + if (active_entities == NULL) + { + goto cleanup; + } + + node_layouts = + investigation_graph_view_create_layout( + active_entities, + width + ); + + if (node_layouts == NULL) + { + investigation_graph_view_set_layout_error( + error, + "Impossible d'allouer la disposition des entités." + ); + + goto cleanup; + } + + node_layouts_by_identifier = + investigation_graph_view_create_layout_index( + node_layouts + ); + + if (node_layouts_by_identifier == NULL) + { + investigation_graph_view_set_layout_error( + error, + "Impossible d'indexer la disposition des entités." + ); + + goto cleanup; + } + + relations = + investigation_graph_model_list_relations( + graph_view->graph_model, + error + ); + + if (relations == NULL) + { + goto cleanup; + } + + relation_pair_counts = + investigation_graph_view_count_relation_pairs( + relations, + node_layouts_by_identifier + ); + + if (relation_pair_counts == NULL) + { + investigation_graph_view_set_layout_error( + error, + "Impossible de préparer les relations du graphe." + ); + + goto cleanup; + } + + graph_view->node_layouts = + node_layouts; + + graph_view->node_layouts_by_identifier = + node_layouts_by_identifier; + + graph_view->relations = + relations; + + graph_view->relation_pair_counts = + relation_pair_counts; + + node_layouts = + NULL; + + node_layouts_by_identifier = + NULL; + + relations = + NULL; + + relation_pair_counts = + NULL; + + success = + TRUE; + +cleanup: + + if (relation_pair_counts != NULL) + { + g_hash_table_unref( + relation_pair_counts + ); + } + + if (relations != NULL) + { + g_ptr_array_unref( + relations + ); + } + + if (node_layouts_by_identifier != NULL) + { + g_hash_table_unref( + node_layouts_by_identifier + ); + } + + if (node_layouts != NULL) + { + g_ptr_array_unref( + node_layouts + ); + } + + if (active_entities != NULL) + { + g_ptr_array_unref( + active_entities + ); + } + + return success; +} + /** * @brief Construit une clé déterministe pour un couple source/cible. */ @@ -1702,7 +2477,8 @@ static void investigation_graph_view_draw_entity( cairo_t *cairo_context, const EntityRecord *entity_record, double x, - double y + double y, + gboolean selected ) { const char *title = @@ -1729,28 +2505,57 @@ static void investigation_graph_view_draw_entity( INVESTIGATION_GRAPH_VIEW_NODE_RADIUS ); - cairo_set_source_rgb( - cairo_context, - 0.16, - 0.18, - 0.22 - ); + if (selected) + { + cairo_set_source_rgb( + cairo_context, + 0.20, + 0.23, + 0.30 + ); + } + else + { + cairo_set_source_rgb( + cairo_context, + 0.16, + 0.18, + 0.22 + ); + } cairo_fill_preserve( cairo_context ); - cairo_set_source_rgb( - cairo_context, - 0.40, - 0.45, - 0.55 - ); + if (selected) + { + cairo_set_source_rgb( + cairo_context, + 0.56, + 0.72, + 1.00 + ); - cairo_set_line_width( - cairo_context, - 1.5 - ); + cairo_set_line_width( + cairo_context, + 3.0 + ); + } + else + { + cairo_set_source_rgb( + cairo_context, + 0.40, + 0.45, + 0.55 + ); + + cairo_set_line_width( + cairo_context, + 1.5 + ); + } cairo_stroke( cairo_context @@ -1903,24 +2708,6 @@ static void investigation_graph_view_draw( InvestigationGraphView *graph_view = user_data; - GPtrArray *active_entities = - NULL; - - GPtrArray *node_layouts = - NULL; - - GPtrArray *relations = - NULL; - - GHashTable *layout_index = - NULL; - - GHashTable *pair_counts = - NULL; - - GError *error = - NULL; - guint layout_position = 0; @@ -1957,18 +2744,34 @@ static void investigation_graph_view_draw( return; } - active_entities = - investigation_graph_view_list_active_entities( - graph_view->graph_model, - &error + if (graph_view->layout_error_message != NULL) + { + investigation_graph_view_draw_message( + cairo_context, + graph_view->layout_error_message, + width, + height ); - if (active_entities == NULL) - { - goto draw_error; + return; } - if (active_entities->len == 0) + if (graph_view->node_layouts == NULL || + graph_view->node_layouts_by_identifier == NULL || + graph_view->relations == NULL || + graph_view->relation_pair_counts == NULL) + { + investigation_graph_view_draw_message( + cairo_context, + "La disposition du graphe est indisponible", + width, + height + ); + + return; + } + + if (graph_view->node_layouts->len == 0) { investigation_graph_view_draw_message( cairo_context, @@ -1977,42 +2780,7 @@ static void investigation_graph_view_draw( height ); - goto cleanup; - } - - node_layouts = - investigation_graph_view_create_layout( - active_entities, - width - ); - - layout_index = - investigation_graph_view_create_layout_index( - node_layouts - ); - - relations = - investigation_graph_model_list_relations( - graph_view->graph_model, - &error - ); - - if (node_layouts == NULL || - layout_index == NULL || - relations == NULL) - { - goto draw_error; - } - - pair_counts = - investigation_graph_view_count_relation_pairs( - relations, - layout_index - ); - - if (pair_counts == NULL) - { - goto draw_error; + return; } /* @@ -2041,86 +2809,53 @@ static void investigation_graph_view_draw( */ investigation_graph_view_draw_relations( cairo_context, - relations, - layout_index, - pair_counts + graph_view->relations, + graph_view->node_layouts_by_identifier, + graph_view->relation_pair_counts ); for (layout_position = 0; - layout_position < node_layouts->len; + layout_position < graph_view->node_layouts->len; layout_position++) { const InvestigationGraphNodeLayout *node_layout = g_ptr_array_index( - node_layouts, + graph_view->node_layouts, layout_position ); + if (node_layout == + graph_view->selected_node) + { + continue; + } + investigation_graph_view_draw_entity( cairo_context, node_layout->entity_record, node_layout->x, - node_layout->y + node_layout->y, + FALSE + ); + } + + /* + * Le nœud sélectionné est dessiné en dernier pour rester au premier plan. + */ + if (graph_view->selected_node != NULL) + { + investigation_graph_view_draw_entity( + cairo_context, + graph_view->selected_node->entity_record, + graph_view->selected_node->x, + graph_view->selected_node->y, + TRUE ); } cairo_restore( cairo_context ); - - goto cleanup; - -draw_error: - - investigation_graph_view_draw_message( - cairo_context, - error != NULL - ? error->message - : "Impossible de préparer le rendu du graphe", - width, - height - ); - -cleanup: - - g_clear_error( - &error - ); - - if (pair_counts != NULL) - { - g_hash_table_unref( - pair_counts - ); - } - - if (layout_index != NULL) - { - g_hash_table_unref( - layout_index - ); - } - - if (relations != NULL) - { - g_ptr_array_unref( - relations - ); - } - - if (node_layouts != NULL) - { - g_ptr_array_unref( - node_layouts - ); - } - - if (active_entities != NULL) - { - g_ptr_array_unref( - active_entities - ); - } } InvestigationGraphView *investigation_graph_view_new(void) @@ -2190,6 +2925,9 @@ InvestigationGraphView *investigation_graph_view_new(void) graph_view->drag_gesture = gtk_gesture_drag_new(); + graph_view->click_gesture = + gtk_gesture_click_new(); + graph_view->scroll_controller = gtk_event_controller_scroll_new( GTK_EVENT_CONTROLLER_SCROLL_VERTICAL @@ -2200,6 +2938,7 @@ InvestigationGraphView *investigation_graph_view_new(void) if (graph_view->zoom_gesture == NULL || graph_view->drag_gesture == NULL || + graph_view->click_gesture == NULL || graph_view->scroll_controller == NULL || graph_view->motion_controller == NULL) { @@ -2262,6 +3001,31 @@ InvestigationGraphView *investigation_graph_view_new(void) graph_view ); + gtk_gesture_single_set_button( + GTK_GESTURE_SINGLE( + graph_view->click_gesture + ), + GDK_BUTTON_PRIMARY + ); + + g_signal_connect( + graph_view->click_gesture, + "pressed", + G_CALLBACK( + investigation_graph_view_on_click_pressed + ), + graph_view + ); + + g_signal_connect( + graph_view->click_gesture, + "released", + G_CALLBACK( + investigation_graph_view_on_click_released + ), + graph_view + ); + g_signal_connect( graph_view->scroll_controller, "scroll", @@ -2312,6 +3076,22 @@ InvestigationGraphView *investigation_graph_view_new(void) ) ); + gtk_widget_add_controller( + graph_view->drawing_area, + GTK_EVENT_CONTROLLER( + graph_view->click_gesture + ) + ); + + /* + * Les deux gestes doivent partager la même séquence : le glisser peut + * réclamer l'événement sans empêcher GtkGestureClick de terminer le clic. + */ + gtk_gesture_group( + graph_view->drag_gesture, + graph_view->click_gesture + ); + gtk_widget_add_controller( graph_view->drawing_area, graph_view->scroll_controller @@ -2371,14 +3151,65 @@ void investigation_graph_view_set_graph( const InvestigationGraphModel *graph_model ) { + GError *error = + NULL; + + int width = + 640; + if (graph_view == NULL) { return; } + if (graph_model == NULL) + { + investigation_graph_view_clear( + graph_view + ); + + return; + } + + investigation_graph_view_clear_layout( + graph_view + ); + graph_view->graph_model = graph_model; + if (graph_view->drawing_area != NULL) + { + width = + gtk_widget_get_width( + graph_view->drawing_area + ); + + if (width <= 0) + { + width = + 640; + } + } + + if (!investigation_graph_view_build_layout( + graph_view, + width, + &error + )) + { + graph_view->layout_error_message = + g_strdup( + error != NULL + ? error->message + : "Impossible de préparer la disposition du graphe." + ); + } + + g_clear_error( + &error + ); + investigation_graph_view_reset_view( graph_view ); @@ -2393,6 +3224,10 @@ void investigation_graph_view_clear( return; } + investigation_graph_view_clear_layout( + graph_view + ); + graph_view->graph_model = NULL; @@ -2401,6 +3236,47 @@ void investigation_graph_view_clear( ); } +void investigation_graph_view_set_selection_callback( + InvestigationGraphView *graph_view, + InvestigationGraphViewSelectionCallback callback, + gpointer user_data +) +{ + if (graph_view == NULL) + { + return; + } + + graph_view->selection_callback = + callback; + + graph_view->selection_user_data = + user_data; +} + +const EntityRecord *investigation_graph_view_get_selected_entity( + const InvestigationGraphView *graph_view +) +{ + if (graph_view == NULL || + graph_view->selected_node == NULL) + { + return NULL; + } + + return graph_view->selected_node->entity_record; +} + +void investigation_graph_view_clear_selection( + InvestigationGraphView *graph_view +) +{ + investigation_graph_view_set_selected_node( + graph_view, + NULL + ); +} + void investigation_graph_view_reset_view( InvestigationGraphView *graph_view ) @@ -2446,8 +3322,9 @@ void investigation_graph_view_reset_view( graph_view->zoom_gesture_anchor_y = 0.0; - graph_view->dragging = - FALSE; + investigation_graph_view_cancel_drag( + graph_view + ); if (graph_view->drawing_area != NULL) { @@ -2462,6 +3339,68 @@ void investigation_graph_view_reset_view( } } +void investigation_graph_view_reset_layout( + InvestigationGraphView *graph_view +) +{ + GError *error = + NULL; + + int width = + 640; + + if (graph_view == NULL) + { + return; + } + + investigation_graph_view_clear_layout( + graph_view + ); + + if (graph_view->graph_model != NULL) + { + if (graph_view->drawing_area != NULL) + { + width = + gtk_widget_get_width( + graph_view->drawing_area + ); + + if (width <= 0) + { + width = + 640; + } + } + + if (!investigation_graph_view_build_layout( + graph_view, + width, + &error + )) + { + graph_view->layout_error_message = + g_strdup( + error != NULL + ? error->message + : "Impossible de réinitialiser la disposition." + ); + } + } + + g_clear_error( + &error + ); + + if (graph_view->drawing_area != NULL) + { + gtk_widget_queue_draw( + graph_view->drawing_area + ); + } +} + void investigation_graph_view_free( InvestigationGraphView *graph_view ) @@ -2471,6 +3410,16 @@ void investigation_graph_view_free( return; } + graph_view->selection_callback = + NULL; + + graph_view->selection_user_data = + NULL; + + investigation_graph_view_clear_layout( + graph_view + ); + graph_view->graph_model = NULL; @@ -2521,6 +3470,24 @@ void investigation_graph_view_free( NULL; } + if (graph_view->click_gesture != NULL) + { + g_signal_handlers_disconnect_by_data( + graph_view->click_gesture, + graph_view + ); + + gtk_widget_remove_controller( + graph_view->drawing_area, + GTK_EVENT_CONTROLLER( + graph_view->click_gesture + ) + ); + + graph_view->click_gesture = + NULL; + } + if (graph_view->scroll_controller != NULL) { g_signal_handlers_disconnect_by_data( @@ -2561,4 +3528,3 @@ void investigation_graph_view_free( graph_view ); } - diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 9a53ba5..320474e 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -5,6 +5,7 @@ #include "widgets/workspace.h" +#include "widgets/entity_details_panel.h" #include "widgets/investigation_graph_view.h" #include @@ -45,7 +46,10 @@ struct Workspace GtkWidget *graph_title_label; GtkWidget *graph_details_label; + GtkWidget *graph_view_page; + InvestigationGraphView *graph_view; + EntityDetailsPanel *entity_details_panel; GtkWidget *node_page; @@ -250,6 +254,50 @@ static const char *workspace_get_integrity_status_text( } } +/** + * @brief Transmet la sélection du graphe au volet de détails. + */ +static void workspace_on_graph_entity_selected( + const EntityRecord *entity_record, + gpointer user_data +) +{ + Workspace *workspace = + user_data; + + if (workspace == NULL || + workspace->entity_details_panel == NULL) + { + return; + } + + entity_details_panel_set_entity( + workspace->entity_details_panel, + entity_record + ); +} + +/** + * @brief Désélectionne le nœud lorsque le volet est fermé manuellement. + */ +static void workspace_on_entity_details_closed( + gpointer user_data +) +{ + Workspace *workspace = + user_data; + + if (workspace == NULL || + workspace->graph_view == NULL) + { + return; + } + + investigation_graph_view_clear_selection( + workspace->graph_view + ); +} + /** * @brief Affiche l'état principal courant du Workspace. * @@ -896,12 +944,12 @@ Workspace *workspace_new(void) ); /* - * Vue graphique en lecture seule. + * Page graphique : canvas principal et volet de détails superposé. */ - workspace->graph_view = - investigation_graph_view_new(); + workspace->graph_view_page = + gtk_overlay_new(); - if (workspace->graph_view == NULL) + if (workspace->graph_view_page == NULL) { workspace_free( workspace @@ -910,11 +958,75 @@ Workspace *workspace_new(void) return NULL; } - gtk_stack_add_named( - GTK_STACK(workspace->stack), + gtk_widget_set_hexpand( + workspace->graph_view_page, + TRUE + ); + + gtk_widget_set_vexpand( + workspace->graph_view_page, + TRUE + ); + + workspace->graph_view = + investigation_graph_view_new(); + + workspace->entity_details_panel = + entity_details_panel_new(); + + if (workspace->graph_view == NULL || + workspace->entity_details_panel == NULL) + { + workspace_free( + workspace + ); + + return NULL; + } + + investigation_graph_view_set_selection_callback( + workspace->graph_view, + workspace_on_graph_entity_selected, + workspace + ); + + entity_details_panel_set_close_callback( + workspace->entity_details_panel, + workspace_on_entity_details_closed, + workspace + ); + + gtk_overlay_set_child( + GTK_OVERLAY( + workspace->graph_view_page + ), investigation_graph_view_get_widget( workspace->graph_view + ) + ); + + gtk_overlay_add_overlay( + GTK_OVERLAY( + workspace->graph_view_page ), + entity_details_panel_get_widget( + workspace->entity_details_panel + ) + ); + + gtk_overlay_set_clip_overlay( + GTK_OVERLAY( + workspace->graph_view_page + ), + entity_details_panel_get_widget( + workspace->entity_details_panel + ), + TRUE + ); + + gtk_stack_add_named( + GTK_STACK(workspace->stack), + workspace->graph_view_page, WORKSPACE_PAGE_GRAPH_VIEW ); @@ -1283,6 +1395,10 @@ void workspace_set_graph_loading( ); } + entity_details_panel_clear( + workspace->entity_details_panel + ); + investigation_graph_view_clear( workspace->graph_view ); @@ -1355,6 +1471,10 @@ void workspace_set_graph( ); } + entity_details_panel_clear( + workspace->entity_details_panel + ); + workspace->graph_model = graph_model; @@ -1419,6 +1539,10 @@ void workspace_set_graph_error( ); } + entity_details_panel_clear( + workspace->entity_details_panel + ); + investigation_graph_view_clear( workspace->graph_view ); @@ -1484,6 +1608,10 @@ void workspace_clear_graph( ); } + entity_details_panel_clear( + workspace->entity_details_panel + ); + investigation_graph_view_clear( workspace->graph_view ); @@ -1549,6 +1677,22 @@ void workspace_free(Workspace *workspace) return; } + investigation_graph_view_set_selection_callback( + workspace->graph_view, + NULL, + NULL + ); + + entity_details_panel_set_close_callback( + workspace->entity_details_panel, + NULL, + NULL + ); + + entity_details_panel_clear( + workspace->entity_details_panel + ); + investigation_graph_view_clear( workspace->graph_view ); @@ -1557,9 +1701,16 @@ void workspace_free(Workspace *workspace) workspace->graph_view ); + entity_details_panel_free( + workspace->entity_details_panel + ); + workspace->graph_view = NULL; + workspace->entity_details_panel = + NULL; + workspace->graph_model = NULL; @@ -1576,4 +1727,3 @@ void workspace_free(Workspace *workspace) g_free(workspace); } -