diff --git a/CHANGELOG.md b/CHANGELOG.md index 133dafc..73faa37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* allow scrolling + ## [0.4.2] - 2024-12-14 ### Changed diff --git a/src/log_viewer.rs b/src/log_viewer.rs index e2dc126..cd71ab7 100644 --- a/src/log_viewer.rs +++ b/src/log_viewer.rs @@ -1,12 +1,18 @@ use bevy::{prelude::*, render::view::RenderLayers, utils::tracing::level_filters::LevelFilter}; -use crate::{debug_log_level::DebugLogLevel, utils}; +use crate::{debug_log_level::DebugLogLevel, utils, ScrollToBottom}; pub const RENDER_LAYER: usize = 55; #[derive(Component)] pub(crate) struct LogViewerMarker; +#[derive(Component, PartialEq)] +pub(crate) enum ScrollState { + Auto, + Manual, +} + #[derive(Resource)] pub(crate) struct LogViewerState { pub(crate) visible: bool, @@ -18,6 +24,7 @@ pub(crate) struct LogViewerState { pub(crate) info_visible: bool, pub(crate) debug_visible: bool, pub(crate) trace_visible: bool, + pub(crate) scroll_state: ScrollState, } impl Default for LogViewerState { @@ -32,6 +39,7 @@ impl Default for LogViewerState { info_visible: true, debug_visible: true, trace_visible: true, + scroll_state: ScrollState::Auto, } } } @@ -39,6 +47,12 @@ impl Default for LogViewerState { #[derive(Component)] pub(crate) struct ListMarker; +#[derive(Component)] +pub(crate) struct ListContainerMarker; + +#[derive(Component)] +pub(crate) struct GoDownBtnMarker; + #[derive(Component)] pub(crate) enum TrafficLightButton { Red, @@ -79,9 +93,11 @@ pub fn setup_log_viewer_ui(mut commands: Commands, log_viewer_res: Res>, mut commands: Commands| { + commands.trigger(ScrollToBottom); + }) + .with_children(|parent| { + // Create a down-arrow icon by rotating a square 45 degrees + // and clipping the overflow at the top. + parent + .spawn(( + Node { + overflow: Overflow::clip_y(), + align_items: AlignItems::End, + justify_content: JustifyContent::Center, + width: Val::Px(16.), + height: Val::Px(8.), + padding: UiRect::bottom(Val::Px(4.)), + ..default() + }, + Name::new("icon_container"), + )) + .with_children(|parent| { + parent.spawn(( + Node { + width: Val::Px(8.), + height: Val::Px(8.), + ..default() + }, + Transform { + rotation: Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + ..default() + }, + BackgroundColor(Color::WHITE), + Name::new("down_arrow"), + )); + }); + }); + // List Container parent .spawn(( Node { height: Val::Percent(100.), - overflow: Overflow { - x: OverflowAxis::Visible, - y: OverflowAxis::Clip, - }, + overflow: Overflow::scroll_y(), ..default() }, Name::new("container"), + ListContainerMarker, )) + .observe(on_drag_scroll) .with_children(|children| { children.spawn(( Node { flex_direction: FlexDirection::Column, position_type: PositionType::Absolute, - bottom: Val::Px(0.), + ..default() + }, + PickingBehavior { + should_block_lower: false, ..default() }, Name::new("list"), @@ -273,3 +355,14 @@ pub fn setup_log_viewer_ui(mut commands: Commands, log_viewer_res: Res>, + mut scroll_positions: Query<&mut ScrollPosition, With>, + mut log_viewer_state: ResMut, +) { + if let Ok(mut scroll_position) = scroll_positions.get_mut(drag.entity()) { + scroll_position.offset_y -= drag.delta.y; + log_viewer_state.scroll_state = ScrollState::Manual; + } +} diff --git a/src/logging.rs b/src/logging.rs index 5650d44..06017c2 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,17 +1,20 @@ use crate::{ debug_log_level::DebugLogLevel, log_viewer::{ - setup_log_viewer_ui, AutoCheckBox, ChipToggle, LevelFilterChip, ListMarker, - LogViewerMarker, LogViewerState, TrafficLightButton, RENDER_LAYER, + setup_log_viewer_ui, AutoCheckBox, ChipToggle, GoDownBtnMarker, LevelFilterChip, + ListContainerMarker, ListMarker, LogViewerMarker, LogViewerState, ScrollState, + TrafficLightButton, RENDER_LAYER, }, utils::{CheckboxIconMarker, ChipLeadingTextMarker}, }; use bevy::{ color::palettes::css, + input::mouse::{MouseScrollUnit, MouseWheel}, log::{ tracing_subscriber::{self, Layer}, BoxedLayer, }, + picking::focus::HoverMap, prelude::*, render::view::RenderLayers, utils::tracing::{self, level_filters::LevelFilter, Subscriber}, @@ -19,6 +22,8 @@ use bevy::{ use std::{num::NonZero, sync::mpsc}; use time::{format_description::well_known::iso8601, OffsetDateTime}; +const LOG_LINE_FONT_SIZE: f32 = 8.; + #[derive(Debug, Event, Clone)] struct LogEvent { message: String, @@ -26,6 +31,9 @@ struct LogEvent { timestamp: OffsetDateTime, } +#[derive(Debug, Event, Clone)] +pub(crate) struct ScrollToBottom; + #[derive(Deref, DerefMut)] struct LogEventsReceiver(mpsc::Receiver); @@ -105,6 +113,7 @@ impl Plugin for LogViewerPlugin { app.add_observer(handle_log_viewer_clear); app.add_observer(handle_auto_open_check); app.add_observer(handle_level_filter_chip_toggle); + app.add_observer(handle_scroll_to_bottom); app.add_systems(Startup, setup_log_viewer_ui); @@ -127,7 +136,10 @@ impl Plugin for LogViewerPlugin { // Running update_log_ui in PreUpdate to prevent data races between updating the UI and filtering log lines. // `handle_level_filter_chip_toggle`` can modify the `{level}_visible` fields in `LogViewerState` // while `update_log_ui` is adding new loglines to the viewer in parallel based on older values. - app.add_systems(PreUpdate, (receive_logs, update_log_counts)); + app.add_systems( + PreUpdate, + (manage_scroll_ui_state, receive_logs, update_log_counts).chain(), + ); app.add_systems( Update, @@ -135,6 +147,7 @@ impl Plugin for LogViewerPlugin { on_traffic_light_button, on_auto_open_check, on_level_filter_chip, + (handle_listcontainer_overflow, handle_scroll_update).chain(), ), ); } @@ -487,6 +500,9 @@ fn receive_logs( { commands.trigger(LogViewerVisibility::Show); } + if log_viewer_res.scroll_state == ScrollState::Auto { + commands.trigger(ScrollToBottom); + } } } } @@ -510,12 +526,87 @@ fn add_level_info( )); } +// Align the list to the End until it overflows, then switch to Default for scrolling to work. +fn handle_listcontainer_overflow( + mut commands: Commands, + mut scroll_query: Query<(&mut Node, &ComputedNode, &Children), With>, + child_comp_node_query: Query<&ComputedNode, With>, +) { + if let Ok((mut node, parent_comp_node, children)) = scroll_query.get_single_mut() { + if let Ok(child_comp_node) = child_comp_node_query.get(children[0]) { + let overflown = parent_comp_node.size().y < child_comp_node.size().y; + if !overflown && node.align_items != AlignItems::End { + node.align_items = AlignItems::End; + } else if overflown && node.align_items != AlignItems::Default { + node.align_items = AlignItems::Default; + commands.trigger(ScrollToBottom); + } + } + } +} + +fn manage_scroll_ui_state( + mut log_viewer: ResMut, + mut border_color_q: Query<&mut BorderColor, With>, + mut scroll_to_bottom_btn_q: Query<&mut Node, With>, + mut scroll_query: Query<(&ScrollPosition, &ComputedNode, &Children), With>, + child_comp_node_query: Query<&ComputedNode, With>, +) { + if let Ok((scroll_position, parent_comp_node, children)) = scroll_query.get_single_mut() { + if let Ok(child_comp_node) = child_comp_node_query.get(children[0]) { + // The list is at bottom if the sum of the parent's height and the scroll offset is equal to the child's height. + // We subtract the font size to account for the last log line being partially visible and still count that as being at the bottom. + let is_at_bottom = parent_comp_node.size().y + scroll_position.offset_y + >= child_comp_node.size().y - LOG_LINE_FONT_SIZE; + + if let Ok(mut border_color) = border_color_q.get_single_mut() { + *border_color = if is_at_bottom { + Color::NONE.into() + } else { + css::WHITE.with_alpha(0.25).into() + }; + } + if let Ok(mut scroll_to_bottom_btn) = scroll_to_bottom_btn_q.get_single_mut() { + scroll_to_bottom_btn.display = if is_at_bottom { + Display::None + } else { + Display::Flex + }; + } + + log_viewer.scroll_state = if is_at_bottom { + ScrollState::Auto + } else { + ScrollState::Manual + }; + } + } +} + +fn handle_scroll_to_bottom( + _trigger: Trigger, + mut log_viewer: ResMut, + mut scroll_query: Query<(&mut ScrollPosition, &Children), With>, + computed_node_query: Query<&ComputedNode, With>, +) { + // ListContainerMarker -> ListMarker have a Parent -> Child relationship. + if let Ok((mut scroll_position, children)) = scroll_query.get_single_mut() { + if let Ok(computed_node) = computed_node_query.get(children[0]) { + scroll_position.offset_y = computed_node.size().y; + log_viewer.scroll_state = ScrollState::Auto; + } + } +} + fn spawn_logline(commands: &mut Commands, parent: Entity, event: &LogEvent) -> Entity { let dbg_level = DebugLogLevel::from(*event.metadata.level()); - const LOG_LINE_FONT_SIZE: f32 = 8.; commands .spawn(( + PickingBehavior { + should_block_lower: false, + ..default() + }, TextLayout::default().with_linebreak(LineBreak::AnyCharacter), Text::default(), // Label, @@ -594,3 +685,28 @@ fn on_level_filter_chip( } } } + +fn handle_scroll_update( + mut mouse_wheel_events: EventReader, + hover_map: Res, + mut scrolled_node_query: Query<&mut ScrollPosition, With>, +) { + for mouse_wheel_event in mouse_wheel_events.read() { + let (dx, dy) = match mouse_wheel_event.unit { + MouseScrollUnit::Line => ( + mouse_wheel_event.x * LOG_LINE_FONT_SIZE, + mouse_wheel_event.y * LOG_LINE_FONT_SIZE, + ), + MouseScrollUnit::Pixel => (mouse_wheel_event.x, mouse_wheel_event.y), + }; + + for (_pointer, pointer_map) in hover_map.iter() { + for (entity, _hit) in pointer_map.iter() { + if let Ok(mut scroll_position) = scrolled_node_query.get_mut(*entity) { + scroll_position.offset_x -= dx; + scroll_position.offset_y -= dy; + } + } + } + } +}