313 lines
14 KiB
Rust
313 lines
14 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxyui::units::{IntoPixels, Pixels};
|
|
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId};
|
|
use settings::Setting as _;
|
|
|
|
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
|
|
use crate::ai::blocklist::agent_view::AgentViewController;
|
|
use crate::appearance::Appearance;
|
|
use crate::settings::{InputModeSettings, InputSettings};
|
|
use crate::terminal::block_list_viewport::InputMode;
|
|
use crate::terminal::input::inline_menu::message_bar::INLINE_MENU_BORDER_WIDTH;
|
|
use crate::terminal::input::inline_menu::styles::{CONTENT_BORDER_WIDTH, CONTENT_VERTICAL_PADDING};
|
|
use crate::terminal::input::inline_menu::view::QUERY_RESULT_RENDERER_STYLES;
|
|
use crate::terminal::input::inline_menu::InlineMenuType;
|
|
use crate::terminal::input::message_bar::common::standard_message_bar_height;
|
|
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
|
|
use crate::terminal::{element_size_at_last_frame, SizeInfo};
|
|
|
|
const DEFAULT_VISIBLE_RESULT_COUNT: f32 = 9.;
|
|
const MIN_VISIBLE_RESULT_COUNT: f32 = 3.;
|
|
const MAX_VISIBLE_RESULT_COUNT: f32 = 20.;
|
|
|
|
/// Returns the pixel height of the content panel for a given number of visible result rows,
|
|
/// including vertical padding and border widths.
|
|
fn content_height_for_row_count(count: f32, appearance: &Appearance) -> f32 {
|
|
(QUERY_RESULT_RENDERER_STYLES.result_item_height_fn)(appearance) * count
|
|
+ (CONTENT_VERTICAL_PADDING * 2.)
|
|
+ (CONTENT_BORDER_WIDTH * 2.)
|
|
}
|
|
|
|
/// Owns the positioning and sizing state for all inline menus.
|
|
///
|
|
/// Computes whether the menu renders above or below the input — which depends on the user's
|
|
/// `InputMode` setting and the available viewport space — and acts as the sole source of truth
|
|
/// for per-menu content heights. The view never stores height; it reads from and writes to
|
|
/// this positioner exclusively.
|
|
pub struct InlineMenuPositioner {
|
|
size_info: SizeInfo,
|
|
terminal_content_position_id: String,
|
|
/// The save position ID for the input box (prompt, editor, message bar, padding)
|
|
/// WITHOUT the inline menu. Used to measure the input height from the previous frame
|
|
/// for pane-aware height capping.
|
|
input_save_position_id: String,
|
|
window_id: WindowId,
|
|
should_render_below_input: bool,
|
|
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
|
agent_view_controller: ModelHandle<AgentViewController>,
|
|
/// Per-menu custom content heights, set by resize.
|
|
custom_content_heights: HashMap<InlineMenuType, f32>,
|
|
}
|
|
|
|
impl InlineMenuPositioner {
|
|
pub fn new(
|
|
suggestions_mode_model: &ModelHandle<InputSuggestionsModeModel>,
|
|
agent_view_controller: &ModelHandle<AgentViewController>,
|
|
terminal_content_position_id: String,
|
|
input_save_position_id: String,
|
|
size_info: SizeInfo,
|
|
window_id: WindowId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
let persisted_heights = InputSettings::as_ref(ctx)
|
|
.inline_menu_custom_content_heights
|
|
.value()
|
|
.clone();
|
|
ctx.subscribe_to_model(suggestions_mode_model, |me, _, _, ctx| {
|
|
let suggestions_mode_model = me.suggestions_mode_model.as_ref(ctx);
|
|
if suggestions_mode_model.is_inline_menu_open() {
|
|
if me.agent_view_controller.as_ref(ctx).is_active() {
|
|
me.should_render_below_input = false;
|
|
} else {
|
|
match *InputModeSettings::as_ref(ctx).input_mode {
|
|
InputMode::PinnedToBottom => {
|
|
me.should_render_below_input = false;
|
|
}
|
|
InputMode::PinnedToTop => me.should_render_below_input = true,
|
|
InputMode::Waterfall => {
|
|
let terminal_view_height = element_size_at_last_frame(
|
|
&me.terminal_content_position_id,
|
|
me.window_id,
|
|
ctx,
|
|
)
|
|
.map(|size| size.y())
|
|
.unwrap_or(me.size_info.pane_height_px().as_f32());
|
|
|
|
me.should_render_below_input = terminal_view_height
|
|
< me.inline_menu_height(ctx) + me.menu_frame_height(ctx);
|
|
}
|
|
}
|
|
}
|
|
ctx.emit(Updated::Repositioned);
|
|
}
|
|
});
|
|
|
|
Self {
|
|
size_info,
|
|
terminal_content_position_id,
|
|
input_save_position_id,
|
|
window_id,
|
|
suggestions_mode_model: suggestions_mode_model.clone(),
|
|
agent_view_controller: agent_view_controller.clone(),
|
|
should_render_below_input: false,
|
|
custom_content_heights: persisted_heights,
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if the inline menu should be rendered below the input, factoring in the
|
|
/// current input position in the viewport and the available space above/below it.
|
|
///
|
|
/// The preference is always to render the menu above the input, if there is available space.
|
|
pub fn should_render_inline_menu_below_input(&self) -> bool {
|
|
self.should_render_below_input
|
|
}
|
|
|
|
/// Returns `true` if the inline menu should render results in reverse.
|
|
///
|
|
/// If `true`, the 0-index item in the menu is rendered at the bottom of the menu instead of the
|
|
/// top.
|
|
pub(super) fn should_render_results_in_reverse(&self, app: &AppContext) -> bool {
|
|
// History menu should not reverse results - up-arrow history is always cycled
|
|
// using the up arrow key, so the ordering should be consistent.
|
|
if self
|
|
.suggestions_mode_model
|
|
.as_ref(app)
|
|
.is_inline_history_menu()
|
|
{
|
|
return false;
|
|
}
|
|
self.should_render_below_input
|
|
}
|
|
|
|
/// Returns an 'inset' to be applied to the y position of the top of the blocklist element when:
|
|
/// * in waterfall mode, which is the only mode in which the input may be rendered in a floating
|
|
/// position between the top and bottom of the viewport.
|
|
/// * the inline menu is visible
|
|
/// * the menu is rendered above the input
|
|
///
|
|
/// Applying this inset to the blocklist element's position simulates 'scrolling' the blocklist
|
|
/// downwards by the height of the inline menu. Effectively, this "slides" blocklist content
|
|
/// upwards when the menu is open.
|
|
///
|
|
/// This inset application is done in one of two ways in waterfall mode:
|
|
/// * If there is no gap, we subtract the 'inset', equivalent to the inline menu
|
|
/// height from the max height constraint of the blocklist element
|
|
/// * If there _is_ a gap, we subtract the inset from painted visible blocklist element originj
|
|
/// at paint time.
|
|
///
|
|
/// When there is no gap, that's pretty much all we need to do.
|
|
///
|
|
/// When there is a gap, there is further accounting to be done: this blocklist translation is
|
|
/// applied at paint-time to reduce the surface area of logic that needs to be aware of the
|
|
/// inline menu visiblity/height (for example, the sumtree doesn't need to be aware of this at
|
|
/// all). This means, however, we do need to account for inset value in logic that translates
|
|
/// artifacts of rendering (layout/paint) back to the data model -- main in places where the
|
|
/// sumtree heights are dependent on the laid out input size (the inline menu is part of the
|
|
/// input element subtree). The main areas considered are:
|
|
///
|
|
/// * Gap height calculation (which is one of the items in the sumtree)
|
|
/// * Scroll position
|
|
///
|
|
/// Both of these calculations depend on the laid out input size, which must account for the
|
|
/// visible inline menu height.
|
|
///
|
|
/// Here there be dragons - the key 'contract' of this method is that it returns the exact
|
|
/// height of the inline menu when it is open and rendered above the input.
|
|
///
|
|
/// If and how the blocklist element logic uses this value depends on whether or not it's
|
|
/// in waterfall mode and whether or not there is an active gap.
|
|
pub(in crate::terminal) fn blocklist_top_inset_when_in_waterfall_mode(
|
|
&self,
|
|
app: &AppContext,
|
|
) -> Option<Pixels> {
|
|
if self
|
|
.suggestions_mode_model
|
|
.as_ref(app)
|
|
.is_inline_menu_open()
|
|
{
|
|
(!self.should_render_below_input)
|
|
.then(|| (self.inline_menu_height(app) + self.menu_frame_height(app)).into_pixels())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Returns `true` when we should skip rendering the menu entirely because the pane is too short
|
|
/// (otherwise we run into weird clipping and overflow bugs).
|
|
pub fn should_hide_inline_menu_for_pane_size(&self, app: &AppContext) -> bool {
|
|
let one_row_height = content_height_for_row_count(0., Appearance::as_ref(app));
|
|
self.max_content_height_for_pane(app) < one_row_height
|
|
}
|
|
|
|
/// Returns the content panel height for the currently active menu, or the default
|
|
/// height if no custom height has been set via resize.
|
|
///
|
|
/// The returned height is capped so the total menu container plus the non-menu input
|
|
/// elements do not exceed the pane height.
|
|
pub fn inline_menu_height(&self, app: &AppContext) -> f32 {
|
|
self.base_content_height(app)
|
|
.min(self.max_content_height_for_pane(app))
|
|
}
|
|
|
|
/// The user-customized or default content height, without any pane-aware capping.
|
|
fn base_content_height(&self, app: &AppContext) -> f32 {
|
|
self.suggestions_mode_model
|
|
.as_ref(app)
|
|
.inline_menu_type()
|
|
.and_then(|mt| self.custom_content_heights.get(&mt).copied())
|
|
.unwrap_or_else(|| {
|
|
content_height_for_row_count(DEFAULT_VISIBLE_RESULT_COUNT, Appearance::as_ref(app))
|
|
})
|
|
}
|
|
|
|
/// The non-content height of the menu container (header, message bar, borders).
|
|
fn menu_frame_height(&self, app: &AppContext) -> f32 {
|
|
let header_height = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
|
HEADER_ROW_HEIGHT + HEADER_BORDER * 2.
|
|
} else {
|
|
0.
|
|
};
|
|
if self.agent_view_controller.as_ref(app).is_active() {
|
|
header_height
|
|
} else {
|
|
header_height + standard_message_bar_height(app) + INLINE_MENU_BORDER_WIDTH
|
|
}
|
|
}
|
|
|
|
/// Applies a resize delta to the given menu type's content height, clamping to the
|
|
/// allowed min/max range. Returns the actual height change applied, or `None` if the
|
|
/// delta had no effect.
|
|
pub fn apply_resize_delta(
|
|
&mut self,
|
|
menu_type: InlineMenuType,
|
|
delta: f32,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Option<f32> {
|
|
let current = self.inline_menu_height(ctx);
|
|
let appearance = Appearance::as_ref(ctx);
|
|
|
|
let min_height = content_height_for_row_count(MIN_VISIBLE_RESULT_COUNT, appearance);
|
|
let max_height = content_height_for_row_count(MAX_VISIBLE_RESULT_COUNT, appearance)
|
|
.min(self.max_content_height_for_pane(ctx))
|
|
.max(min_height);
|
|
|
|
// When the menu renders above input, the header is at the top — dragging
|
|
// down (positive delta) shrinks the menu. When below, the header is at
|
|
// the bottom — dragging down grows the menu.
|
|
let new_height = if self.should_render_below_input {
|
|
current + delta
|
|
} else {
|
|
current - delta
|
|
}
|
|
.clamp(min_height, max_height);
|
|
let height_change = new_height - current;
|
|
if height_change.abs() <= f32::EPSILON {
|
|
return None;
|
|
}
|
|
|
|
self.custom_content_heights.insert(menu_type, new_height);
|
|
|
|
ctx.emit(Updated::Resized);
|
|
Some(height_change)
|
|
}
|
|
|
|
/// Persists the current custom content heights to settings.
|
|
/// Call this when a resize drag finishes rather than on every drag event.
|
|
pub fn persist_custom_content_heights(&self, ctx: &mut ModelContext<Self>) {
|
|
InputSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
let _ = settings
|
|
.inline_menu_custom_content_heights
|
|
.set_value(self.custom_content_heights.clone(), ctx);
|
|
});
|
|
}
|
|
|
|
/// Updates the cached pane size info.
|
|
pub fn set_size_info(&mut self, size_info: SizeInfo, ctx: &mut ModelContext<Self>) {
|
|
let height_changed =
|
|
(self.size_info.pane_height_px().as_f32() - size_info.pane_height_px().as_f32()).abs()
|
|
> f32::EPSILON;
|
|
self.size_info = size_info;
|
|
if height_changed {
|
|
ctx.emit(Updated::Resized);
|
|
}
|
|
}
|
|
|
|
/// Returns the maximum content panel height that keeps the entire menu container
|
|
/// plus the non-menu input elements within the pane height.
|
|
fn max_content_height_for_pane(&self, app: &AppContext) -> f32 {
|
|
let pane_height = self.size_info.pane_height_px().as_f32();
|
|
let overhead = self.menu_frame_height(app);
|
|
|
|
let input_box_height =
|
|
element_size_at_last_frame(&self.input_save_position_id, self.window_id, app)
|
|
.map(|size| size.y())
|
|
.unwrap_or(0.);
|
|
|
|
// We clamp to 0 rather than to MIN_VISIBLE_RESULT_COUNT so that, if the pane is too
|
|
// short to fit even a minimal menu, we still let the menu height shrink to nothing
|
|
// rather than overflowing into an adjacent pane.
|
|
(pane_height - input_box_height - overhead).max(0.)
|
|
}
|
|
}
|
|
|
|
pub enum Updated {
|
|
Repositioned,
|
|
Resized,
|
|
}
|
|
|
|
impl Entity for InlineMenuPositioner {
|
|
type Event = Updated;
|
|
}
|