logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
24
crates/csv_preview/Cargo.toml
Normal file
24
crates/csv_preview/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "csv_preview"
|
||||
version = "0.1.0"
|
||||
publish.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/csv_preview.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
feature_flags.workspace = true
|
||||
gpui.workspace = true
|
||||
editor.workspace = true
|
||||
ui.workspace = true
|
||||
workspace.workspace = true
|
||||
log.workspace = true
|
||||
text.workspace = true
|
||||
|
||||
[features]
|
||||
dev-tools = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
1
crates/csv_preview/LICENSE-GPL
Symbolic link
1
crates/csv_preview/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
333
crates/csv_preview/src/csv_preview.rs
Normal file
333
crates/csv_preview/src/csv_preview.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
use editor::{Editor, EditorEvent};
|
||||
use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag};
|
||||
use gpui::{
|
||||
AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::table_data_engine::TableDataEngine;
|
||||
use ui::{
|
||||
AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState,
|
||||
TableResizeBehavior, prelude::*,
|
||||
};
|
||||
use workspace::{Item, SplitDirection, Workspace};
|
||||
|
||||
use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent};
|
||||
|
||||
mod parser;
|
||||
mod renderer;
|
||||
mod settings;
|
||||
mod table_data_engine;
|
||||
mod types;
|
||||
|
||||
actions!(csv, [OpenPreview, OpenPreviewToTheSide]);
|
||||
|
||||
pub struct TabularDataPreviewFeatureFlag;
|
||||
|
||||
impl FeatureFlag for TabularDataPreviewFeatureFlag {
|
||||
const NAME: &'static str = "tabular-data-preview";
|
||||
type Value = PresenceFlag;
|
||||
}
|
||||
register_feature_flag!(TabularDataPreviewFeatureFlag);
|
||||
|
||||
pub struct CsvPreviewView {
|
||||
pub(crate) engine: TableDataEngine,
|
||||
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
active_editor_state: EditorState,
|
||||
pub(crate) table_interaction_state: Entity<TableInteractionState>,
|
||||
pub(crate) column_widths: ColumnWidths,
|
||||
pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
|
||||
pub(crate) settings: CsvPreviewSettings,
|
||||
/// Performance metrics for debugging and monitoring CSV operations.
|
||||
pub(crate) performance_metrics: PerformanceMetrics,
|
||||
pub(crate) list_state: gpui::ListState,
|
||||
/// Time when the last parsing operation ended, used for smart debouncing
|
||||
pub(crate) last_parse_end_time: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.observe_new(|workspace: &mut Workspace, _, _| {
|
||||
CsvPreviewView::register(workspace);
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
|
||||
impl CsvPreviewView {
|
||||
pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
|
||||
// plus 1 for the row identifier column
|
||||
let cols = self.engine.contents.headers.cols() + 1;
|
||||
let line_number_width = self.calculate_row_identifier_column_width();
|
||||
|
||||
let mut widths: Vec<AbsoluteLength> = vec![AbsoluteLength::Pixels(px(150.)); cols];
|
||||
widths[0] = AbsoluteLength::Pixels(px(line_number_width));
|
||||
|
||||
let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
|
||||
resize_behaviors[0] = TableResizeBehavior::None;
|
||||
|
||||
self.column_widths.widths.update(cx, |state, _cx| {
|
||||
if state.cols() != cols {
|
||||
*state = ResizableColumnsState::new(cols, widths, resize_behaviors);
|
||||
} else {
|
||||
state.set_column_configuration(
|
||||
0,
|
||||
AbsoluteLength::Pixels(px(line_number_width)),
|
||||
TableResizeBehavior::None,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn register(workspace: &mut Workspace) {
|
||||
workspace.register_action_renderer(|div, _, _, cx| {
|
||||
div.when(cx.has_flag::<TabularDataPreviewFeatureFlag>(), |div| {
|
||||
div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| {
|
||||
if let Some(editor) = workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.act_as::<Editor>(cx))
|
||||
.filter(|editor| Self::is_csv_file(editor, cx))
|
||||
{
|
||||
let csv_preview = Self::new(&editor, cx);
|
||||
workspace.active_pane().update(cx, |pane, cx| {
|
||||
let existing = pane
|
||||
.items_of_type::<CsvPreviewView>()
|
||||
.find(|view| view.read(cx).active_editor_state.editor == editor);
|
||||
if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) {
|
||||
pane.activate_item(idx, true, true, window, cx);
|
||||
} else {
|
||||
pane.add_item(Box::new(csv_preview), true, true, None, window, cx);
|
||||
}
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(
|
||||
|workspace, _: &OpenPreviewToTheSide, window, cx| {
|
||||
if let Some(editor) = workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.act_as::<Editor>(cx))
|
||||
.filter(|editor| Self::is_csv_file(editor, cx))
|
||||
{
|
||||
let csv_preview = Self::new(&editor, cx);
|
||||
let pane = workspace
|
||||
.find_pane_in_direction(SplitDirection::Right, cx)
|
||||
.unwrap_or_else(|| {
|
||||
workspace.split_pane(
|
||||
workspace.active_pane().clone(),
|
||||
SplitDirection::Right,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
pane.update(cx, |pane, cx| {
|
||||
let existing =
|
||||
pane.items_of_type::<CsvPreviewView>().find(|view| {
|
||||
view.read(cx).active_editor_state.editor == editor
|
||||
});
|
||||
if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) {
|
||||
pane.activate_item(idx, true, true, window, cx);
|
||||
} else {
|
||||
pane.add_item(
|
||||
Box::new(csv_preview),
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn new(editor: &Entity<Editor>, cx: &mut Context<Workspace>) -> Entity<Self> {
|
||||
let contents = TableLikeContent::default();
|
||||
let table_interaction_state = cx.new(|cx| {
|
||||
TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::<
|
||||
editor::EditorSettingsScrollbarProxy,
|
||||
>())
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription = cx.subscribe(
|
||||
editor,
|
||||
|this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| {
|
||||
match event {
|
||||
EditorEvent::Edited { .. } | EditorEvent::DirtyChanged => {
|
||||
this.parse_csv_from_active_editor(true, cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let mut view = CsvPreviewView {
|
||||
focus_handle: cx.focus_handle(),
|
||||
active_editor_state: EditorState {
|
||||
editor: editor.clone(),
|
||||
_subscription: subscription,
|
||||
},
|
||||
table_interaction_state,
|
||||
column_widths: ColumnWidths::new(cx, 1),
|
||||
parsing_task: None,
|
||||
performance_metrics: PerformanceMetrics::default(),
|
||||
list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.))
|
||||
.measure_all(),
|
||||
settings: CsvPreviewSettings::default(),
|
||||
last_parse_end_time: None,
|
||||
engine: TableDataEngine::default(),
|
||||
};
|
||||
|
||||
view.parse_csv_from_active_editor(false, cx);
|
||||
view
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn editor_state(&self) -> &EditorState {
|
||||
&self.active_editor_state
|
||||
}
|
||||
pub(crate) fn apply_sort(&mut self) {
|
||||
self.performance_metrics.record("Sort", || {
|
||||
self.engine.apply_sort();
|
||||
});
|
||||
}
|
||||
|
||||
/// Update ordered indices when ordering or content changes
|
||||
pub(crate) fn apply_filter_sort(&mut self) {
|
||||
self.performance_metrics.record("Filter&sort", || {
|
||||
self.engine.calculate_d2d_mapping();
|
||||
});
|
||||
|
||||
// Update list state with filtered row count
|
||||
let visible_rows = self.engine.d2d_mapping().visible_row_count();
|
||||
self.list_state =
|
||||
gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all();
|
||||
}
|
||||
|
||||
pub fn resolve_active_item_as_csv_editor(
|
||||
workspace: &Workspace,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Option<Entity<Editor>> {
|
||||
let editor = workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.act_as::<Editor>(cx))?;
|
||||
Self::is_csv_file(&editor, cx).then_some(editor)
|
||||
}
|
||||
|
||||
fn is_csv_file(editor: &Entity<Editor>, cx: &App) -> bool {
|
||||
editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.and_then(|buffer| {
|
||||
buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.and_then(|file| file.path().extension())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("csv"))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for CsvPreviewView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<()> for CsvPreviewView {}
|
||||
|
||||
impl Item for CsvPreviewView {
|
||||
type Event = ();
|
||||
|
||||
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
|
||||
Some(Icon::new(IconName::FileDoc))
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
|
||||
self.editor_state()
|
||||
.editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.and_then(|b| {
|
||||
let file = b.read(cx).file()?;
|
||||
let local_file = file.as_local()?;
|
||||
local_file
|
||||
.abs_path(cx)
|
||||
.file_name()
|
||||
.map(|name| format!("Preview {}", name.to_string_lossy()).into())
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from("CSV Preview"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PerformanceMetrics {
|
||||
/// Map of timing metrics with their duration and measurement time.
|
||||
pub timings: HashMap<&'static str, (Duration, Instant)>,
|
||||
/// List of display indices that were rendered in the current frame.
|
||||
pub rendered_indices: Vec<usize>,
|
||||
}
|
||||
impl PerformanceMetrics {
|
||||
pub fn record<F, R>(&mut self, name: &'static str, mut f: F) -> R
|
||||
where
|
||||
F: FnMut() -> R,
|
||||
{
|
||||
let start_time = Instant::now();
|
||||
let ret = f();
|
||||
let duration = start_time.elapsed();
|
||||
self.timings.insert(name, (duration, Instant::now()));
|
||||
ret
|
||||
}
|
||||
|
||||
/// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago`
|
||||
pub fn display(&self) -> String {
|
||||
let mut metrics = self.timings.iter().collect::<Vec<_>>();
|
||||
metrics.sort_by_key(|&(name, _)| *name);
|
||||
metrics
|
||||
.iter()
|
||||
.map(|(name, (duration, time))| {
|
||||
let took = duration.as_secs_f32() * 1000.;
|
||||
let ago = time.elapsed().as_secs();
|
||||
format!("{name}: {took:.2}ms {ago}s ago")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Get timing for a specific metric
|
||||
pub fn get_timing(&self, name: &str) -> Option<Duration> {
|
||||
self.timings.get(name).map(|(duration, _)| *duration)
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds state of column widths for a table component in CSV preview.
|
||||
pub(crate) struct ColumnWidths {
|
||||
pub widths: Entity<ResizableColumnsState>,
|
||||
}
|
||||
|
||||
impl ColumnWidths {
|
||||
pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
|
||||
Self {
|
||||
widths: cx.new(|_cx| {
|
||||
ResizableColumnsState::new(
|
||||
cols,
|
||||
vec![AbsoluteLength::Pixels(px(150.)); cols],
|
||||
vec![ui::TableResizeBehavior::Resizable; cols],
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
510
crates/csv_preview/src/parser.rs
Normal file
510
crates/csv_preview/src/parser.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
types::TableLikeContent,
|
||||
types::{LineNumber, TableCell},
|
||||
};
|
||||
use editor::Editor;
|
||||
use gpui::{AppContext, Context, Entity, Subscription, Task};
|
||||
use std::time::{Duration, Instant};
|
||||
use text::BufferSnapshot;
|
||||
use ui::{SharedString, table_row::TableRow};
|
||||
|
||||
pub(crate) const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
pub(crate) struct EditorState {
|
||||
pub editor: Entity<Editor>,
|
||||
pub _subscription: Subscription,
|
||||
}
|
||||
|
||||
impl CsvPreviewView {
|
||||
pub(crate) fn parse_csv_from_active_editor(
|
||||
&mut self,
|
||||
wait_for_debounce: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let editor = self.active_editor_state.editor.clone();
|
||||
self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx));
|
||||
}
|
||||
|
||||
fn parse_csv_in_background(
|
||||
&mut self,
|
||||
wait_for_debounce: bool,
|
||||
editor: Entity<Editor>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
if wait_for_debounce {
|
||||
// Smart debouncing: check if cooldown period has already passed
|
||||
let now = Instant::now();
|
||||
let should_wait = view.update(cx, |view, _| {
|
||||
if let Some(last_end) = view.last_parse_end_time {
|
||||
let cooldown_until = last_end + REPARSE_DEBOUNCE;
|
||||
if now < cooldown_until {
|
||||
Some(cooldown_until - now)
|
||||
} else {
|
||||
None // Cooldown already passed, parse immediately
|
||||
}
|
||||
} else {
|
||||
None // First parse, no debounce
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(wait_duration) = should_wait {
|
||||
cx.background_executor().timer(wait_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
let buffer_snapshot = view.update(cx, |_, cx| {
|
||||
editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.map(|b| b.read(cx).text_snapshot())
|
||||
})?;
|
||||
|
||||
let Some(buffer_snapshot) = buffer_snapshot else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let instant = Instant::now();
|
||||
let parsed_csv = cx
|
||||
.background_spawn(async move { from_buffer(&buffer_snapshot) })
|
||||
.await;
|
||||
let parse_duration = instant.elapsed();
|
||||
let parse_end_time: Instant = Instant::now();
|
||||
log::debug!("Parsed CSV in {}ms", parse_duration.as_millis());
|
||||
view.update(cx, move |view, cx| {
|
||||
view.performance_metrics
|
||||
.timings
|
||||
.insert("Parsing", (parse_duration, Instant::now()));
|
||||
|
||||
log::debug!("Parsed {} rows", parsed_csv.rows.len());
|
||||
view.engine.contents = parsed_csv;
|
||||
view.sync_column_widths(cx);
|
||||
view.last_parse_end_time = Some(parse_end_time);
|
||||
|
||||
view.apply_filter_sort();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_buffer(buffer_snapshot: &BufferSnapshot) -> TableLikeContent {
|
||||
let text = buffer_snapshot.text();
|
||||
|
||||
if text.trim().is_empty() {
|
||||
return TableLikeContent::default();
|
||||
}
|
||||
|
||||
let (parsed_cells_with_positions, line_numbers) = parse_csv_with_positions(&text);
|
||||
if parsed_cells_with_positions.is_empty() {
|
||||
return TableLikeContent::default();
|
||||
}
|
||||
let raw_headers = parsed_cells_with_positions[0].clone();
|
||||
|
||||
// Calculating the longest row, as CSV might have less headers than max row width
|
||||
let Some(max_number_of_cols) = parsed_cells_with_positions.iter().map(|r| r.len()).max() else {
|
||||
return TableLikeContent::default();
|
||||
};
|
||||
|
||||
// Convert to TableCell objects with buffer positions
|
||||
let headers = create_table_row(&buffer_snapshot, max_number_of_cols, raw_headers);
|
||||
|
||||
let rows = parsed_cells_with_positions
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.map(|row| create_table_row(&buffer_snapshot, max_number_of_cols, row))
|
||||
.collect();
|
||||
|
||||
let row_line_numbers = line_numbers.into_iter().skip(1).collect();
|
||||
|
||||
TableLikeContent {
|
||||
headers,
|
||||
rows,
|
||||
line_numbers: row_line_numbers,
|
||||
number_of_cols: max_number_of_cols,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse CSV and track byte positions for each cell
|
||||
fn parse_csv_with_positions(
|
||||
text: &str,
|
||||
) -> (
|
||||
Vec<Vec<(SharedString, std::ops::Range<usize>)>>,
|
||||
Vec<LineNumber>,
|
||||
) {
|
||||
let mut rows = Vec::new();
|
||||
let mut line_numbers = Vec::new();
|
||||
let mut current_row: Vec<(SharedString, std::ops::Range<usize>)> = Vec::new();
|
||||
let mut current_field = String::new();
|
||||
let mut field_start_offset = 0;
|
||||
let mut current_offset = 0;
|
||||
let mut in_quotes = false;
|
||||
let mut current_line = 1; // 1-based line numbering
|
||||
let mut row_start_line = 1;
|
||||
let mut chars = text.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
let char_byte_len = ch.len_utf8();
|
||||
|
||||
match ch {
|
||||
'"' => {
|
||||
if in_quotes {
|
||||
if chars.peek() == Some(&'"') {
|
||||
// Escaped quote
|
||||
chars.next();
|
||||
current_field.push('"');
|
||||
current_offset += 1; // Skip the second quote
|
||||
} else {
|
||||
// End of quoted field
|
||||
in_quotes = false;
|
||||
}
|
||||
} else {
|
||||
// Start of quoted field
|
||||
in_quotes = true;
|
||||
if current_field.is_empty() {
|
||||
// Include the opening quote in the range
|
||||
field_start_offset = current_offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
',' if !in_quotes => {
|
||||
// Field separator
|
||||
let field_end_offset = current_offset;
|
||||
if current_field.is_empty() && !in_quotes {
|
||||
field_start_offset = current_offset;
|
||||
}
|
||||
current_row.push((
|
||||
current_field.clone().into(),
|
||||
field_start_offset..field_end_offset,
|
||||
));
|
||||
current_field.clear();
|
||||
field_start_offset = current_offset + char_byte_len;
|
||||
}
|
||||
'\n' => {
|
||||
current_line += 1;
|
||||
if !in_quotes {
|
||||
// Row separator (only when not inside quotes)
|
||||
let field_end_offset = current_offset;
|
||||
if current_field.is_empty() && current_row.is_empty() {
|
||||
field_start_offset = 0;
|
||||
}
|
||||
current_row.push((
|
||||
current_field.clone().into(),
|
||||
field_start_offset..field_end_offset,
|
||||
));
|
||||
current_field.clear();
|
||||
|
||||
// Only add non-empty rows
|
||||
if !current_row.is_empty()
|
||||
&& !current_row.iter().all(|(field, _)| field.trim().is_empty())
|
||||
{
|
||||
rows.push(current_row);
|
||||
// Add line number info for this row
|
||||
let line_info = if row_start_line == current_line - 1 {
|
||||
LineNumber::Line(row_start_line)
|
||||
} else {
|
||||
LineNumber::LineRange(row_start_line, current_line - 1)
|
||||
};
|
||||
line_numbers.push(line_info);
|
||||
}
|
||||
current_row = Vec::new();
|
||||
row_start_line = current_line;
|
||||
field_start_offset = current_offset + char_byte_len;
|
||||
} else {
|
||||
// Newline inside quotes - preserve it
|
||||
current_field.push(ch);
|
||||
}
|
||||
}
|
||||
'\r' => {
|
||||
if chars.peek() == Some(&'\n') {
|
||||
// Handle Windows line endings (\r\n): account for \r byte, let \n be handled next
|
||||
current_offset += char_byte_len;
|
||||
continue;
|
||||
} else {
|
||||
// Standalone \r
|
||||
current_line += 1;
|
||||
if !in_quotes {
|
||||
// Row separator (only when not inside quotes)
|
||||
let field_end_offset = current_offset;
|
||||
current_row.push((
|
||||
current_field.clone().into(),
|
||||
field_start_offset..field_end_offset,
|
||||
));
|
||||
current_field.clear();
|
||||
|
||||
// Only add non-empty rows
|
||||
if !current_row.is_empty()
|
||||
&& !current_row.iter().all(|(field, _)| field.trim().is_empty())
|
||||
{
|
||||
rows.push(current_row);
|
||||
// Add line number info for this row
|
||||
let line_info = if row_start_line == current_line - 1 {
|
||||
LineNumber::Line(row_start_line)
|
||||
} else {
|
||||
LineNumber::LineRange(row_start_line, current_line - 1)
|
||||
};
|
||||
line_numbers.push(line_info);
|
||||
}
|
||||
current_row = Vec::new();
|
||||
row_start_line = current_line;
|
||||
field_start_offset = current_offset + char_byte_len;
|
||||
} else {
|
||||
// \r inside quotes - preserve it
|
||||
current_field.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if current_field.is_empty() && !in_quotes {
|
||||
field_start_offset = current_offset;
|
||||
}
|
||||
current_field.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
current_offset += char_byte_len;
|
||||
}
|
||||
|
||||
// Add the last field and row if not empty
|
||||
if !current_field.is_empty() || !current_row.is_empty() {
|
||||
let field_end_offset = current_offset;
|
||||
current_row.push((
|
||||
current_field.clone().into(),
|
||||
field_start_offset..field_end_offset,
|
||||
));
|
||||
}
|
||||
if !current_row.is_empty() && !current_row.iter().all(|(field, _)| field.trim().is_empty()) {
|
||||
rows.push(current_row);
|
||||
// Add line number info for the last row
|
||||
let line_info = if row_start_line == current_line {
|
||||
LineNumber::Line(row_start_line)
|
||||
} else {
|
||||
LineNumber::LineRange(row_start_line, current_line)
|
||||
};
|
||||
line_numbers.push(line_info);
|
||||
}
|
||||
|
||||
(rows, line_numbers)
|
||||
}
|
||||
|
||||
fn create_table_row(
|
||||
buffer_snapshot: &BufferSnapshot,
|
||||
max_number_of_cols: usize,
|
||||
row: Vec<(SharedString, std::ops::Range<usize>)>,
|
||||
) -> TableRow<TableCell> {
|
||||
let mut raw_row = row
|
||||
.into_iter()
|
||||
.map(|(content, range)| {
|
||||
TableCell::from_buffer_position(content, range.start, range.end, &buffer_snapshot)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let append_elements = max_number_of_cols - raw_row.len();
|
||||
if append_elements > 0 {
|
||||
for _ in 0..append_elements {
|
||||
raw_row.push(TableCell::Virtual);
|
||||
}
|
||||
}
|
||||
|
||||
TableRow::from_vec(raw_row, max_number_of_cols)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_csv_parsing_basic() {
|
||||
let csv_data = "Name,Age,City\nJohn,30,New York\nJane,25,Los Angeles";
|
||||
let parsed = TableLikeContent::from_str(csv_data.to_string());
|
||||
|
||||
assert_eq!(parsed.headers.cols(), 3);
|
||||
assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name");
|
||||
assert_eq!(parsed.headers[1].display_value().unwrap().as_ref(), "Age");
|
||||
assert_eq!(parsed.headers[2].display_value().unwrap().as_ref(), "City");
|
||||
|
||||
assert_eq!(parsed.rows.len(), 2);
|
||||
assert_eq!(parsed.rows[0][0].display_value().unwrap().as_ref(), "John");
|
||||
assert_eq!(parsed.rows[0][1].display_value().unwrap().as_ref(), "30");
|
||||
assert_eq!(
|
||||
parsed.rows[0][2].display_value().unwrap().as_ref(),
|
||||
"New York"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csv_parsing_with_quotes() {
|
||||
let csv_data = r#"Name,Description
|
||||
"John Doe","A person with ""special"" characters"
|
||||
Jane,"Simple name""#;
|
||||
let parsed = TableLikeContent::from_str(csv_data.to_string());
|
||||
|
||||
assert_eq!(parsed.headers.cols(), 2);
|
||||
assert_eq!(parsed.rows.len(), 2);
|
||||
assert_eq!(
|
||||
parsed.rows[0][1].display_value().unwrap().as_ref(),
|
||||
r#"A person with "special" characters"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csv_parsing_with_newlines_in_quotes() {
|
||||
let csv_data = "Name,Description,Status\n\"John\nDoe\",\"A person with\nmultiple lines\",Active\n\"Jane Smith\",\"Simple\",\"Also\nActive\"";
|
||||
let parsed = TableLikeContent::from_str(csv_data.to_string());
|
||||
|
||||
assert_eq!(parsed.headers.cols(), 3);
|
||||
assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name");
|
||||
assert_eq!(
|
||||
parsed.headers[1].display_value().unwrap().as_ref(),
|
||||
"Description"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.headers[2].display_value().unwrap().as_ref(),
|
||||
"Status"
|
||||
);
|
||||
|
||||
assert_eq!(parsed.rows.len(), 2);
|
||||
assert_eq!(
|
||||
parsed.rows[0][0].display_value().unwrap().as_ref(),
|
||||
"John\nDoe"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rows[0][1].display_value().unwrap().as_ref(),
|
||||
"A person with\nmultiple lines"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rows[0][2].display_value().unwrap().as_ref(),
|
||||
"Active"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.rows[1][0].display_value().unwrap().as_ref(),
|
||||
"Jane Smith"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rows[1][1].display_value().unwrap().as_ref(),
|
||||
"Simple"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rows[1][2].display_value().unwrap().as_ref(),
|
||||
"Also\nActive"
|
||||
);
|
||||
|
||||
// Check line numbers
|
||||
assert_eq!(parsed.line_numbers.len(), 2);
|
||||
match &parsed.line_numbers[0] {
|
||||
LineNumber::LineRange(start, end) => {
|
||||
assert_eq!(start, &2);
|
||||
assert_eq!(end, &4);
|
||||
}
|
||||
_ => panic!("Expected LineRange for multiline row"),
|
||||
}
|
||||
match &parsed.line_numbers[1] {
|
||||
LineNumber::LineRange(start, end) => {
|
||||
assert_eq!(start, &5);
|
||||
assert_eq!(end, &6);
|
||||
}
|
||||
_ => panic!("Expected LineRange for second multiline row"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_csv() {
|
||||
let parsed = TableLikeContent::from_str("".to_string());
|
||||
assert_eq!(parsed.headers.cols(), 0);
|
||||
assert!(parsed.rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csv_parsing_quote_offset_handling() {
|
||||
let csv_data = r#"first,"se,cond",third"#;
|
||||
let (parsed_cells, _) = parse_csv_with_positions(csv_data);
|
||||
|
||||
assert_eq!(parsed_cells.len(), 1); // One row
|
||||
assert_eq!(parsed_cells[0].len(), 3); // Three cells
|
||||
|
||||
// first: 0..5 (no quotes)
|
||||
let (content1, range1) = &parsed_cells[0][0];
|
||||
assert_eq!(content1.as_ref(), "first");
|
||||
assert_eq!(*range1, 0..5);
|
||||
|
||||
// "se,cond": 6..15 (includes quotes in range, content without quotes)
|
||||
let (content2, range2) = &parsed_cells[0][1];
|
||||
assert_eq!(content2.as_ref(), "se,cond");
|
||||
assert_eq!(*range2, 6..15);
|
||||
|
||||
// third: 16..21 (no quotes)
|
||||
let (content3, range3) = &parsed_cells[0][2];
|
||||
assert_eq!(content3.as_ref(), "third");
|
||||
assert_eq!(*range3, 16..21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csv_parsing_complex_quotes() {
|
||||
let csv_data = r#"id,"name with spaces","description, with commas",status
|
||||
1,"John Doe","A person with ""quotes"" and, commas",active
|
||||
2,"Jane Smith","Simple description",inactive"#;
|
||||
let (parsed_cells, _) = parse_csv_with_positions(csv_data);
|
||||
|
||||
assert_eq!(parsed_cells.len(), 3); // header + 2 rows
|
||||
|
||||
// Check header row
|
||||
let header_row = &parsed_cells[0];
|
||||
assert_eq!(header_row.len(), 4);
|
||||
|
||||
// id: 0..2
|
||||
assert_eq!(header_row[0].0.as_ref(), "id");
|
||||
assert_eq!(header_row[0].1, 0..2);
|
||||
|
||||
// "name with spaces": 3..21 (includes quotes)
|
||||
assert_eq!(header_row[1].0.as_ref(), "name with spaces");
|
||||
assert_eq!(header_row[1].1, 3..21);
|
||||
|
||||
// "description, with commas": 22..48 (includes quotes)
|
||||
assert_eq!(header_row[2].0.as_ref(), "description, with commas");
|
||||
assert_eq!(header_row[2].1, 22..48);
|
||||
|
||||
// status: 49..55
|
||||
assert_eq!(header_row[3].0.as_ref(), "status");
|
||||
assert_eq!(header_row[3].1, 49..55);
|
||||
|
||||
// Check first data row
|
||||
let first_row = &parsed_cells[1];
|
||||
assert_eq!(first_row.len(), 4);
|
||||
|
||||
// 1: 56..57
|
||||
assert_eq!(first_row[0].0.as_ref(), "1");
|
||||
assert_eq!(first_row[0].1, 56..57);
|
||||
|
||||
// "John Doe": 58..68 (includes quotes)
|
||||
assert_eq!(first_row[1].0.as_ref(), "John Doe");
|
||||
assert_eq!(first_row[1].1, 58..68);
|
||||
|
||||
// Content should be stripped of quotes but include escaped quotes
|
||||
assert_eq!(
|
||||
first_row[2].0.as_ref(),
|
||||
r#"A person with "quotes" and, commas"#
|
||||
);
|
||||
// The range should include the outer quotes: 69..107
|
||||
assert_eq!(first_row[2].1, 69..107);
|
||||
|
||||
// active: 108..114
|
||||
assert_eq!(first_row[3].0.as_ref(), "active");
|
||||
assert_eq!(first_row[3].1, 108..114);
|
||||
}
|
||||
}
|
||||
|
||||
impl TableLikeContent {
|
||||
#[cfg(test)]
|
||||
pub fn from_str(text: String) -> Self {
|
||||
use text::{Buffer, BufferId, ReplicaId};
|
||||
|
||||
let buffer_id = BufferId::new(1).unwrap();
|
||||
let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, text);
|
||||
let snapshot = buffer.snapshot();
|
||||
from_buffer(snapshot)
|
||||
}
|
||||
}
|
||||
8
crates/csv_preview/src/renderer.rs
Normal file
8
crates/csv_preview/src/renderer.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[cfg(feature = "dev-tools")]
|
||||
mod performance_metrics_overlay;
|
||||
mod preview_view;
|
||||
mod render_table;
|
||||
mod row_identifiers;
|
||||
mod settings;
|
||||
mod table_cell;
|
||||
mod table_header;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Performance metrics overlay for CSV preview debugging.
|
||||
//!
|
||||
//! Provides a semi-transparent overlay in the bottom-right corner showing
|
||||
//! CSV parsing performance metrics for developer experience.
|
||||
|
||||
use ui::{ActiveTheme, Context, IntoElement, ParentElement, Styled, StyledTypography, div};
|
||||
|
||||
use crate::{CsvPreviewView, PerformanceMetrics};
|
||||
|
||||
impl CsvPreviewView {
|
||||
/// Renders a semi-transparent performance metrics overlay in the bottom-right corner.
|
||||
///
|
||||
/// Shows CSV parsing duration for debugging and performance monitoring.
|
||||
/// The overlay is positioned absolutely and styled with reduced opacity.
|
||||
pub(crate) fn render_performance_metrics_overlay(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
|
||||
let children = div()
|
||||
.absolute()
|
||||
.top_24()
|
||||
.right_4()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.bg(theme.colors().editor_background)
|
||||
.border_1()
|
||||
.border_color(theme.colors().border)
|
||||
.rounded_md()
|
||||
.opacity(0.75)
|
||||
.text_xs()
|
||||
.font_buffer(cx)
|
||||
.text_color(theme.colors().text_muted)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child("Performance metrics:")
|
||||
.children(
|
||||
format_performance_metrics(&self.performance_metrics)
|
||||
.into_iter()
|
||||
.map(|line| div().child(line)),
|
||||
);
|
||||
|
||||
// Clear rendered indices to prepare for next frame
|
||||
self.performance_metrics.rendered_indices.clear();
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
fn format_performance_metrics(metrics: &PerformanceMetrics) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Add timing metrics using the display method
|
||||
let timing_display = metrics.display();
|
||||
if !timing_display.is_empty() {
|
||||
lines.extend(timing_display.lines().map(|line| format!("- {}", line)));
|
||||
} else {
|
||||
lines.push("- No timing data yet".to_string());
|
||||
}
|
||||
|
||||
// Add rendered indices information
|
||||
if metrics.rendered_indices.is_empty() {
|
||||
lines.push("- Rendered: none".to_string());
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"- Rendered: {} rows",
|
||||
metrics.rendered_indices.len()
|
||||
));
|
||||
if metrics.rendered_indices.len() <= 20 {
|
||||
// Show indices if not too many
|
||||
lines.push(format!(" {:?}", metrics.rendered_indices));
|
||||
} else {
|
||||
// Show first/last few if too many
|
||||
let first_few = &metrics.rendered_indices[..5];
|
||||
let last_few = &metrics.rendered_indices[metrics.rendered_indices.len() - 5..];
|
||||
lines.push(format!(" {:?}\n..{:?}", first_few, last_few));
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
65
crates/csv_preview/src/renderer/preview_view.rs
Normal file
65
crates/csv_preview/src/renderer/preview_view.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use ui::{div, prelude::*};
|
||||
|
||||
use crate::CsvPreviewView;
|
||||
|
||||
impl Render for CsvPreviewView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
|
||||
let render_prep_start = Instant::now();
|
||||
let table_with_settings = v_flex()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.bg(theme.colors().editor_background)
|
||||
.track_focus(&self.focus_handle)
|
||||
.child(self.render_settings_panel(window, cx))
|
||||
.child({
|
||||
if self.engine.contents.number_of_cols == 0 {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_32()
|
||||
.text_ui(cx)
|
||||
.font_buffer(cx)
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("No CSV content to display")
|
||||
.into_any_element()
|
||||
} else {
|
||||
self.create_table(&self.column_widths.widths, cx)
|
||||
}
|
||||
});
|
||||
|
||||
let render_prep_duration = render_prep_start.elapsed();
|
||||
self.performance_metrics.timings.insert(
|
||||
"render_prep",
|
||||
(render_prep_duration, std::time::Instant::now()),
|
||||
);
|
||||
|
||||
let div = div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.h_full()
|
||||
.child(table_with_settings);
|
||||
|
||||
#[cfg(feature = "dev-tools")]
|
||||
let show_perf_metrics_overlay = self.settings.show_perf_metrics_overlay;
|
||||
|
||||
#[cfg(feature = "dev-tools")]
|
||||
let div = div.when(show_perf_metrics_overlay, |div| {
|
||||
div.child(self.render_performance_metrics_overlay(cx))
|
||||
});
|
||||
|
||||
#[cfg(feature = "dev-tools")]
|
||||
if !show_perf_metrics_overlay {
|
||||
self.performance_metrics.rendered_indices.clear();
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "dev-tools"))]
|
||||
self.performance_metrics.rendered_indices.clear();
|
||||
|
||||
div
|
||||
}
|
||||
}
|
||||
165
crates/csv_preview/src/renderer/render_table.rs
Normal file
165
crates/csv_preview/src/renderer/render_table.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use crate::types::TableCell;
|
||||
use gpui::{AnyElement, Entity};
|
||||
use std::ops::Range;
|
||||
use ui::{ColumnWidthConfig, ResizableColumnsState, Table, UncheckedTableRow, div, prelude::*};
|
||||
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
settings::RowRenderMechanism,
|
||||
types::{AnyColumn, DisplayCellId, DisplayRow},
|
||||
};
|
||||
|
||||
impl CsvPreviewView {
|
||||
/// Creates a new table.
|
||||
/// Column number is derived from the `ResizableColumnsState` entity.
|
||||
pub(crate) fn create_table(
|
||||
&self,
|
||||
current_widths: &Entity<ResizableColumnsState>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx)
|
||||
}
|
||||
|
||||
fn create_table_inner(
|
||||
&self,
|
||||
row_count: usize,
|
||||
current_widths: &Entity<ResizableColumnsState>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let cols = current_widths.read(cx).cols();
|
||||
// Create headers array with interactive elements
|
||||
let mut headers = Vec::with_capacity(cols);
|
||||
|
||||
headers.push(self.create_row_identifier_header(cx));
|
||||
|
||||
// Add the actual CSV headers with sort buttons
|
||||
for i in 0..(cols - 1) {
|
||||
let header_text = self
|
||||
.engine
|
||||
.contents
|
||||
.headers
|
||||
.get(AnyColumn(i))
|
||||
.and_then(|h| h.display_value().cloned())
|
||||
.unwrap_or_else(|| format!("Col {}", i + 1).into());
|
||||
|
||||
headers.push(self.create_header_element_with_sort_button(
|
||||
header_text,
|
||||
cx,
|
||||
AnyColumn::from(i),
|
||||
));
|
||||
}
|
||||
|
||||
Table::new(cols)
|
||||
.interactable(&self.table_interaction_state)
|
||||
.striped()
|
||||
.width_config(ColumnWidthConfig::Resizable(current_widths.clone()))
|
||||
.header(headers)
|
||||
.disable_base_style()
|
||||
.map(|table| {
|
||||
let row_identifier_text_color = cx.theme().colors().editor_line_number;
|
||||
match self.settings.rendering_with {
|
||||
RowRenderMechanism::VariableList => {
|
||||
table.variable_row_height_list(row_count, self.list_state.clone(), {
|
||||
cx.processor(move |this, display_row: usize, _window, cx| {
|
||||
this.performance_metrics.rendered_indices.push(display_row);
|
||||
|
||||
let display_row = DisplayRow(display_row);
|
||||
Self::render_single_table_row(
|
||||
this,
|
||||
cols,
|
||||
display_row,
|
||||
row_identifier_text_color,
|
||||
cx,
|
||||
)
|
||||
.unwrap_or_else(|| panic!("Expected to render a table row"))
|
||||
})
|
||||
})
|
||||
}
|
||||
RowRenderMechanism::UniformList => {
|
||||
table.uniform_list("csv-table", row_count, {
|
||||
cx.processor(move |this, range: Range<usize>, _window, cx| {
|
||||
// Record all display indices in the range for performance metrics
|
||||
this.performance_metrics
|
||||
.rendered_indices
|
||||
.extend(range.clone());
|
||||
|
||||
range
|
||||
.filter_map(|display_index| {
|
||||
Self::render_single_table_row(
|
||||
this,
|
||||
cols,
|
||||
DisplayRow(display_index),
|
||||
row_identifier_text_color,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Render a single table row
|
||||
///
|
||||
/// Used both by UniformList and VariableRowHeightList
|
||||
fn render_single_table_row(
|
||||
this: &CsvPreviewView,
|
||||
cols: usize,
|
||||
display_row: DisplayRow,
|
||||
row_identifier_text_color: gpui::Hsla,
|
||||
cx: &Context<CsvPreviewView>,
|
||||
) -> Option<UncheckedTableRow<AnyElement>> {
|
||||
// Get the actual row index from our sorted indices
|
||||
let data_row = this.engine.d2d_mapping().get_data_row(display_row)?;
|
||||
let row = this.engine.contents.get_row(data_row)?;
|
||||
|
||||
let mut elements = Vec::with_capacity(cols);
|
||||
elements.push(this.create_row_identifier_cell(display_row, data_row, cx)?);
|
||||
|
||||
// Remaining columns: actual CSV data
|
||||
for col in (0..this.engine.contents.number_of_cols).map(AnyColumn) {
|
||||
let table_cell = row.expect_get(col);
|
||||
|
||||
// TODO: Introduce `<null>` cell type
|
||||
let cell_content = table_cell.display_value().cloned().unwrap_or_default();
|
||||
|
||||
let display_cell_id = DisplayCellId::new(display_row, col);
|
||||
|
||||
let cell = div().size_full().whitespace_nowrap().text_ellipsis().child(
|
||||
CsvPreviewView::create_selectable_cell(
|
||||
display_cell_id,
|
||||
cell_content,
|
||||
this.settings.vertical_alignment,
|
||||
cx,
|
||||
),
|
||||
);
|
||||
|
||||
elements.push(
|
||||
div()
|
||||
.size_full()
|
||||
.when(this.settings.show_debug_info, |parent| {
|
||||
parent.child(div().text_color(row_identifier_text_color).child(
|
||||
match table_cell {
|
||||
TableCell::Real { position: pos, .. } => {
|
||||
let slv = pos.start.timestamp().value;
|
||||
let so = pos.start.offset;
|
||||
let elv = pos.end.timestamp().value;
|
||||
let eo = pos.end.offset;
|
||||
format!("Pos {so}(L{slv})-{eo}(L{elv})")
|
||||
}
|
||||
TableCell::Virtual => "Virtual cell".into(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.text_ui(cx)
|
||||
.child(cell)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
Some(elements)
|
||||
}
|
||||
}
|
||||
184
crates/csv_preview/src/renderer/row_identifiers.rs
Normal file
184
crates/csv_preview/src/renderer/row_identifiers.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use ui::{
|
||||
ActiveTheme as _, AnyElement, Button, ButtonCommon as _, ButtonSize, ButtonStyle,
|
||||
Clickable as _, Context, ElementId, IntoElement as _, ParentElement as _, SharedString,
|
||||
Styled as _, StyledTypography as _, Tooltip, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
settings::RowIdentifiers,
|
||||
types::{DataRow, DisplayRow, LineNumber},
|
||||
};
|
||||
|
||||
pub enum RowIdentDisplayMode {
|
||||
/// E.g
|
||||
/// ```text
|
||||
/// 1
|
||||
/// ...
|
||||
/// 5
|
||||
/// ```
|
||||
Vertical,
|
||||
/// E.g.
|
||||
/// ```text
|
||||
/// 1-5
|
||||
/// ```
|
||||
Horizontal,
|
||||
}
|
||||
|
||||
impl LineNumber {
|
||||
pub fn display_string(&self, mode: RowIdentDisplayMode) -> String {
|
||||
match *self {
|
||||
LineNumber::Line(line) => line.to_string(),
|
||||
LineNumber::LineRange(start, end) => match mode {
|
||||
RowIdentDisplayMode::Vertical => {
|
||||
if start + 1 == end {
|
||||
format!("{start}\n{end}")
|
||||
} else {
|
||||
format!("{start}\n...\n{end}")
|
||||
}
|
||||
}
|
||||
RowIdentDisplayMode::Horizontal => {
|
||||
format!("{start}-{end}")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CsvPreviewView {
|
||||
/// Calculate the optimal width for the row identifier column (line numbers or row numbers).
|
||||
///
|
||||
/// This ensures the column is wide enough to display the largest identifier comfortably,
|
||||
/// but not wastefully wide for small files.
|
||||
pub(crate) fn calculate_row_identifier_column_width(&self) -> f32 {
|
||||
match self.settings.numbering_type {
|
||||
RowIdentifiers::SrcLines => self.calculate_line_number_width(),
|
||||
RowIdentifiers::RowNum => self.calculate_row_number_width(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate width needed for line numbers (can be multi-line)
|
||||
fn calculate_line_number_width(&self) -> f32 {
|
||||
// Find the maximum line number that could be displayed
|
||||
let max_line_number = self
|
||||
.engine
|
||||
.contents
|
||||
.line_numbers
|
||||
.iter()
|
||||
.map(|ln| match ln {
|
||||
LineNumber::Line(n) => *n,
|
||||
LineNumber::LineRange(_, end) => *end,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
|
||||
let digit_count = if max_line_number == 0 {
|
||||
1
|
||||
} else {
|
||||
(max_line_number as f32).log10().floor() as usize + 1
|
||||
};
|
||||
|
||||
// if !self.settings.multiline_cells_enabled {
|
||||
// // Uses horizontal line numbers layout like `123-456`. Needs twice the size
|
||||
// digit_count *= 2;
|
||||
// }
|
||||
|
||||
let char_width_px = 9.0; // TODO: get real width of the characters
|
||||
let base_width = (digit_count as f32) * char_width_px;
|
||||
let padding = 20.0;
|
||||
let min_width = 60.0;
|
||||
(base_width + padding).max(min_width)
|
||||
}
|
||||
|
||||
/// Calculate width needed for sequential row numbers
|
||||
fn calculate_row_number_width(&self) -> f32 {
|
||||
let max_row_number = self.engine.contents.rows.len();
|
||||
|
||||
let digit_count = if max_row_number == 0 {
|
||||
1
|
||||
} else {
|
||||
(max_row_number as f32).log10().floor() as usize + 1
|
||||
};
|
||||
|
||||
let char_width_px = 9.0; // TODO: get real width of the characters
|
||||
let base_width = (digit_count as f32) * char_width_px;
|
||||
let padding = 20.0;
|
||||
let min_width = 60.0;
|
||||
(base_width + padding).max(min_width)
|
||||
}
|
||||
|
||||
pub(crate) fn create_row_identifier_header(
|
||||
&self,
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
) -> AnyElement {
|
||||
// First column: row identifier (clickable to toggle between Lines and Rows)
|
||||
let row_identifier_text = match self.settings.numbering_type {
|
||||
RowIdentifiers::SrcLines => "Lines",
|
||||
RowIdentifiers::RowNum => "Rows",
|
||||
};
|
||||
|
||||
let view = cx.entity();
|
||||
let value = div()
|
||||
.font_buffer(cx)
|
||||
.child(
|
||||
Button::new(
|
||||
ElementId::Name("row-identifier-toggle".into()),
|
||||
row_identifier_text,
|
||||
)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.size(ButtonSize::Compact)
|
||||
.tooltip(Tooltip::text(
|
||||
"Toggle between: file line numbers or sequential row numbers",
|
||||
))
|
||||
.on_click(move |_event, _window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.settings.numbering_type = match this.settings.numbering_type {
|
||||
RowIdentifiers::SrcLines => RowIdentifiers::RowNum,
|
||||
RowIdentifiers::RowNum => RowIdentifiers::SrcLines,
|
||||
};
|
||||
this.sync_column_widths(cx);
|
||||
cx.notify();
|
||||
});
|
||||
}),
|
||||
)
|
||||
.into_any_element();
|
||||
value
|
||||
}
|
||||
|
||||
pub(crate) fn create_row_identifier_cell(
|
||||
&self,
|
||||
display_row: DisplayRow,
|
||||
data_row: DataRow,
|
||||
cx: &Context<'_, CsvPreviewView>,
|
||||
) -> Option<AnyElement> {
|
||||
let row_identifier: SharedString = match self.settings.numbering_type {
|
||||
RowIdentifiers::SrcLines => self
|
||||
.engine
|
||||
.contents
|
||||
.line_numbers
|
||||
.get(*data_row)?
|
||||
.display_string(if self.settings.multiline_cells_enabled {
|
||||
RowIdentDisplayMode::Vertical
|
||||
} else {
|
||||
RowIdentDisplayMode::Horizontal
|
||||
})
|
||||
.into(),
|
||||
RowIdentifiers::RowNum => (*display_row + 1).to_string().into(),
|
||||
};
|
||||
|
||||
let value = div()
|
||||
.flex()
|
||||
.px_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.h_full()
|
||||
.text_ui(cx)
|
||||
// Row identifiers are always centered
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.font_buffer(cx)
|
||||
.child(row_identifier)
|
||||
.into_any_element();
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
182
crates/csv_preview/src/renderer/settings.rs
Normal file
182
crates/csv_preview/src/renderer/settings.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use ui::{
|
||||
ActiveTheme as _, AnyElement, ButtonSize, Context, ContextMenu, DropdownMenu, ElementId,
|
||||
IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex,
|
||||
};
|
||||
|
||||
use crate::{CsvPreviewView, settings::VerticalAlignment};
|
||||
|
||||
///// Settings related /////
|
||||
impl CsvPreviewView {
|
||||
/// Render settings panel above the table
|
||||
pub(crate) fn render_settings_panel(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let current_alignment_text = match self.settings.vertical_alignment {
|
||||
VerticalAlignment::Top => "Top",
|
||||
VerticalAlignment::Center => "Center",
|
||||
};
|
||||
|
||||
let view = cx.entity();
|
||||
let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.entry("Top", None, {
|
||||
let view = view.clone();
|
||||
move |_window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.settings.vertical_alignment = VerticalAlignment::Top;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.entry("Center", None, {
|
||||
let view = view.clone();
|
||||
move |_window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.settings.vertical_alignment = VerticalAlignment::Center;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let panel = h_flex()
|
||||
.gap_4()
|
||||
.p_2()
|
||||
.bg(cx.theme().colors().surface_background)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.flex_wrap()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("Text Alignment:"),
|
||||
)
|
||||
.child(
|
||||
DropdownMenu::new(
|
||||
ElementId::Name("vertical-alignment-dropdown".into()),
|
||||
current_alignment_text,
|
||||
alignment_dropdown_menu,
|
||||
)
|
||||
.trigger_size(ButtonSize::Compact)
|
||||
.trigger_tooltip(Tooltip::text(
|
||||
"Choose vertical text alignment within cells",
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(feature = "dev-tools")]
|
||||
let panel = panel.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("Dev-only:"),
|
||||
)
|
||||
.child(create_dev_only_popover_menu(cx)),
|
||||
);
|
||||
|
||||
panel.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-tools")]
|
||||
fn create_dev_only_popover_menu(
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
) -> ui::PopoverMenu<ContextMenu> {
|
||||
use crate::settings::RowRenderMechanism;
|
||||
use ui::{IconButton, IconName, IconPosition, IconSize, PopoverMenu};
|
||||
|
||||
PopoverMenu::new("debug-options-menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("debug-options-trigger", IconName::Settings).icon_size(IconSize::Small),
|
||||
Tooltip::text(
|
||||
"Dev-only section used for debugging purposes.\nWill be removed on public release of CSV feature"
|
||||
),
|
||||
)
|
||||
.menu({
|
||||
let view_entity = cx.entity();
|
||||
move |window, cx| {
|
||||
let view = view_entity.read(cx);
|
||||
let settings = view.settings.clone();
|
||||
Some(ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.header("Rendering Mode")
|
||||
.toggleable_entry(
|
||||
"Variable Height",
|
||||
settings.rendering_with == RowRenderMechanism::VariableList,
|
||||
IconPosition::Start,
|
||||
None,
|
||||
{
|
||||
let view_entity = view_entity.clone();
|
||||
move |_w, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.settings.rendering_with =
|
||||
RowRenderMechanism::VariableList;
|
||||
view.settings.multiline_cells_enabled = true;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.toggleable_entry(
|
||||
"Uniform Height",
|
||||
settings.rendering_with == RowRenderMechanism::UniformList,
|
||||
IconPosition::Start,
|
||||
None,
|
||||
{
|
||||
let view_entity = view_entity.clone();
|
||||
move |_w, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.settings.rendering_with =
|
||||
RowRenderMechanism::UniformList;
|
||||
view.settings.multiline_cells_enabled = false;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.separator()
|
||||
.toggleable_entry(
|
||||
"Show perf metrics",
|
||||
settings.show_perf_metrics_overlay,
|
||||
IconPosition::Start,
|
||||
None,
|
||||
{
|
||||
let view_entity = view_entity.clone();
|
||||
move |_w, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.settings.show_perf_metrics_overlay =
|
||||
!view.settings.show_perf_metrics_overlay;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.toggleable_entry(
|
||||
"Show cell positions",
|
||||
settings.show_debug_info,
|
||||
IconPosition::Start,
|
||||
None,
|
||||
{
|
||||
let view_entity = view_entity.clone();
|
||||
move |_, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.settings.show_debug_info =
|
||||
!view.settings.show_debug_info;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
55
crates/csv_preview/src/renderer/table_cell.rs
Normal file
55
crates/csv_preview/src/renderer/table_cell.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Table Cell Rendering
|
||||
|
||||
use gpui::{AnyElement, ElementId};
|
||||
use ui::{SharedString, Tooltip, div, prelude::*};
|
||||
|
||||
use crate::{CsvPreviewView, settings::VerticalAlignment, types::DisplayCellId};
|
||||
|
||||
impl CsvPreviewView {
|
||||
/// Create selectable table cell with mouse event handlers.
|
||||
pub fn create_selectable_cell(
|
||||
display_cell_id: DisplayCellId,
|
||||
cell_content: SharedString,
|
||||
vertical_alignment: VerticalAlignment,
|
||||
cx: &Context<CsvPreviewView>,
|
||||
) -> AnyElement {
|
||||
create_table_cell(display_cell_id, cell_content, vertical_alignment, cx)
|
||||
// Mouse events handlers will be here
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create styled table cell div element.
|
||||
fn create_table_cell(
|
||||
display_cell_id: DisplayCellId,
|
||||
cell_content: SharedString,
|
||||
vertical_alignment: VerticalAlignment,
|
||||
cx: &Context<'_, CsvPreviewView>,
|
||||
) -> gpui::Stateful<Div> {
|
||||
div()
|
||||
.id(ElementId::Name(
|
||||
format!(
|
||||
"csv-display-cell-{}-{}",
|
||||
*display_cell_id.row, *display_cell_id.col
|
||||
)
|
||||
.into(),
|
||||
))
|
||||
.cursor_pointer()
|
||||
.flex()
|
||||
.h_full()
|
||||
.px_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.map(|div| match vertical_alignment {
|
||||
VerticalAlignment::Top => div.items_start(),
|
||||
VerticalAlignment::Center => div.items_center(),
|
||||
})
|
||||
.map(|div| match vertical_alignment {
|
||||
VerticalAlignment::Top => div.content_start(),
|
||||
VerticalAlignment::Center => div.content_center(),
|
||||
})
|
||||
.font_buffer(cx)
|
||||
.tooltip(Tooltip::text(cell_content.clone()))
|
||||
.child(div().child(cell_content))
|
||||
}
|
||||
90
crates/csv_preview/src/renderer/table_header.rs
Normal file
90
crates/csv_preview/src/renderer/table_header.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use gpui::ElementId;
|
||||
use ui::{Tooltip, prelude::*};
|
||||
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
table_data_engine::sorting_by_column::{AppliedSorting, SortDirection},
|
||||
types::AnyColumn,
|
||||
};
|
||||
|
||||
impl CsvPreviewView {
|
||||
/// Create header for data, which is orderable with text on the left and sort button on the right
|
||||
pub(crate) fn create_header_element_with_sort_button(
|
||||
&self,
|
||||
header_text: SharedString,
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
col_idx: AnyColumn,
|
||||
) -> AnyElement {
|
||||
// CSV data columns: text + filter/sort buttons
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.font_buffer(cx)
|
||||
.child(div().child(header_text))
|
||||
.child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn create_sort_button(
|
||||
&self,
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
col_idx: AnyColumn,
|
||||
) -> Button {
|
||||
let sort_btn = Button::new(
|
||||
ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64),
|
||||
match self.engine.applied_sorting {
|
||||
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
|
||||
SortDirection::Asc => "↓",
|
||||
SortDirection::Desc => "↑",
|
||||
},
|
||||
_ => "↕", // Unsorted/available for sorting
|
||||
},
|
||||
)
|
||||
.size(ButtonSize::Compact)
|
||||
.style(
|
||||
if self
|
||||
.engine
|
||||
.applied_sorting
|
||||
.is_some_and(|o| o.col_idx == col_idx)
|
||||
{
|
||||
ButtonStyle::Filled
|
||||
} else {
|
||||
ButtonStyle::Subtle
|
||||
},
|
||||
)
|
||||
.tooltip(Tooltip::text(match self.engine.applied_sorting {
|
||||
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
|
||||
SortDirection::Asc => "Sorted A-Z. Click to sort Z-A",
|
||||
SortDirection::Desc => "Sorted Z-A. Click to disable sorting",
|
||||
},
|
||||
_ => "Not sorted. Click to sort A-Z",
|
||||
}))
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
let new_sorting = match this.engine.applied_sorting {
|
||||
Some(ordering) if ordering.col_idx == col_idx => {
|
||||
// Same column clicked - cycle through states
|
||||
match ordering.direction {
|
||||
SortDirection::Asc => Some(AppliedSorting {
|
||||
col_idx,
|
||||
direction: SortDirection::Desc,
|
||||
}),
|
||||
SortDirection::Desc => None, // Clear sorting
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Different column or no sorting - start with ascending
|
||||
Some(AppliedSorting {
|
||||
col_idx,
|
||||
direction: SortDirection::Asc,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
this.engine.applied_sorting = new_sorting;
|
||||
this.apply_sort();
|
||||
cx.notify();
|
||||
}));
|
||||
sort_btn
|
||||
}
|
||||
}
|
||||
38
crates/csv_preview/src/settings.rs
Normal file
38
crates/csv_preview/src/settings.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
pub enum RowRenderMechanism {
|
||||
/// More correct for multiline content, but slower.
|
||||
#[allow(dead_code)] // Will be used when settings ui is added
|
||||
VariableList,
|
||||
/// Default behaviour for now while resizable columns are being stabilized.
|
||||
#[default]
|
||||
UniformList,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub enum VerticalAlignment {
|
||||
/// Align text to the top of cells
|
||||
#[default]
|
||||
Top,
|
||||
/// Center text vertically in cells
|
||||
Center,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub enum RowIdentifiers {
|
||||
/// Show original line numbers from CSV file
|
||||
#[default]
|
||||
SrcLines,
|
||||
/// Show sequential row numbers starting from 1
|
||||
RowNum,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct CsvPreviewSettings {
|
||||
pub(crate) rendering_with: RowRenderMechanism,
|
||||
pub(crate) vertical_alignment: VerticalAlignment,
|
||||
pub(crate) numbering_type: RowIdentifiers,
|
||||
pub(crate) show_debug_info: bool,
|
||||
#[cfg(feature = "dev-tools")]
|
||||
pub(crate) show_perf_metrics_overlay: bool,
|
||||
pub(crate) multiline_cells_enabled: bool,
|
||||
}
|
||||
90
crates/csv_preview/src/table_data_engine.rs
Normal file
90
crates/csv_preview/src/table_data_engine.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! This module defines core operations and config of tabular data view (CSV table)
|
||||
//! It operates in 2 coordinate systems:
|
||||
//! - `DataCellId` - indices of src data cells
|
||||
//! - `DisplayCellId` - indices of data after applied transformations like sorting/filtering, which is used to render cell on the screen
|
||||
//!
|
||||
//! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles.
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use ui::table_row::TableRow;
|
||||
|
||||
use crate::{
|
||||
table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows},
|
||||
types::{DataRow, DisplayRow, TableCell, TableLikeContent},
|
||||
};
|
||||
|
||||
pub mod sorting_by_column;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TableDataEngine {
|
||||
pub applied_sorting: Option<AppliedSorting>,
|
||||
d2d_mapping: DisplayToDataMapping,
|
||||
pub contents: TableLikeContent,
|
||||
}
|
||||
|
||||
impl TableDataEngine {
|
||||
pub(crate) fn d2d_mapping(&self) -> &DisplayToDataMapping {
|
||||
&self.d2d_mapping
|
||||
}
|
||||
|
||||
pub(crate) fn apply_sort(&mut self) {
|
||||
self.d2d_mapping
|
||||
.apply_sorting(self.applied_sorting, &self.contents.rows);
|
||||
self.d2d_mapping.merge_mappings();
|
||||
}
|
||||
|
||||
/// Applies sorting and filtering to the data and produces display to data mapping
|
||||
pub(crate) fn calculate_d2d_mapping(&mut self) {
|
||||
self.d2d_mapping
|
||||
.apply_sorting(self.applied_sorting, &self.contents.rows);
|
||||
self.d2d_mapping.merge_mappings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Relation of Display (rendered) rows to Data (src) rows with applied transformations
|
||||
/// Transformations applied:
|
||||
/// - sorting by column
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DisplayToDataMapping {
|
||||
/// All rows sorted, regardless of applied filtering. Applied every time sorting changes
|
||||
pub sorted_rows: Vec<DataRow>,
|
||||
/// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows`
|
||||
pub mapping: Arc<HashMap<DisplayRow, DataRow>>,
|
||||
}
|
||||
|
||||
impl DisplayToDataMapping {
|
||||
/// Get the data row for a given display row
|
||||
pub fn get_data_row(&self, display_row: DisplayRow) -> Option<DataRow> {
|
||||
self.mapping.get(&display_row).copied()
|
||||
}
|
||||
|
||||
/// Get the number of filtered rows
|
||||
pub fn visible_row_count(&self) -> usize {
|
||||
self.mapping.len()
|
||||
}
|
||||
|
||||
/// Computes sorting
|
||||
fn apply_sorting(&mut self, sorting: Option<AppliedSorting>, rows: &[TableRow<TableCell>]) {
|
||||
let data_rows: Vec<DataRow> = (0..rows.len()).map(DataRow).collect();
|
||||
|
||||
let sorted_rows = if let Some(sorting) = sorting {
|
||||
sort_data_rows(&rows, data_rows, sorting)
|
||||
} else {
|
||||
data_rows
|
||||
};
|
||||
|
||||
self.sorted_rows = sorted_rows;
|
||||
}
|
||||
|
||||
/// Take pre-computed sorting and filtering results, and apply them to the mapping
|
||||
fn merge_mappings(&mut self) {
|
||||
self.mapping = Arc::new(
|
||||
self.sorted_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(display, data)| (DisplayRow(display), *data))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use ui::table_row::TableRow;
|
||||
|
||||
use crate::types::{AnyColumn, DataRow, TableCell};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Config or currently active sorting
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AppliedSorting {
|
||||
/// 0-based column index
|
||||
pub col_idx: AnyColumn,
|
||||
/// Direction of sorting (asc/desc)
|
||||
pub direction: SortDirection,
|
||||
}
|
||||
|
||||
pub fn sort_data_rows(
|
||||
content_rows: &[TableRow<TableCell>],
|
||||
mut data_row_ids: Vec<DataRow>,
|
||||
sorting: AppliedSorting,
|
||||
) -> Vec<DataRow> {
|
||||
data_row_ids.sort_by(|&a, &b| {
|
||||
let row_a = &content_rows[*a];
|
||||
let row_b = &content_rows[*b];
|
||||
|
||||
// TODO: Decide how to handle nulls (on top or on bottom)
|
||||
let val_a = row_a
|
||||
.get(sorting.col_idx)
|
||||
.and_then(|tc| tc.display_value())
|
||||
.map(|tc| tc.as_str())
|
||||
.unwrap_or("");
|
||||
let val_b = row_b
|
||||
.get(sorting.col_idx)
|
||||
.and_then(|tc| tc.display_value())
|
||||
.map(|tc| tc.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let cmp = val_a.cmp(val_b);
|
||||
match sorting.direction {
|
||||
SortDirection::Asc => cmp,
|
||||
SortDirection::Desc => cmp.reverse(),
|
||||
}
|
||||
});
|
||||
|
||||
data_row_ids
|
||||
}
|
||||
17
crates/csv_preview/src/types.rs
Normal file
17
crates/csv_preview/src/types.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
pub use coordinates::*;
|
||||
mod coordinates;
|
||||
pub use table_cell::*;
|
||||
mod table_cell;
|
||||
pub use table_like_content::*;
|
||||
mod table_like_content;
|
||||
|
||||
/// Line number information for CSV rows
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum LineNumber {
|
||||
/// Single line row
|
||||
Line(usize),
|
||||
/// Multi-line row spanning from start to end line. Incluisive
|
||||
LineRange(usize, usize),
|
||||
}
|
||||
127
crates/csv_preview/src/types/coordinates.rs
Normal file
127
crates/csv_preview/src/types/coordinates.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Type definitions for CSV table coordinates and cell identifiers.
|
||||
//!
|
||||
//! Provides newtypes for self-documenting coordinate systems:
|
||||
//! - Display coordinates: Visual positions in rendered table
|
||||
//! - Data coordinates: Original CSV data positions
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
///// Rows /////
|
||||
/// Visual row position in rendered table.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct DisplayRow(pub usize);
|
||||
|
||||
impl DisplayRow {
|
||||
/// Create a new display row
|
||||
pub fn new(row: usize) -> Self {
|
||||
Self(row)
|
||||
}
|
||||
|
||||
/// Get the inner row value
|
||||
pub fn get(self) -> usize {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DisplayRow {
|
||||
type Target = usize;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Original CSV row position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct DataRow(pub usize);
|
||||
|
||||
impl DataRow {
|
||||
/// Create a new data row
|
||||
pub fn new(row: usize) -> Self {
|
||||
Self(row)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DataRow {
|
||||
type Target = usize;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for DisplayRow {
|
||||
fn from(row: usize) -> Self {
|
||||
DisplayRow::new(row)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for DataRow {
|
||||
fn from(row: usize) -> Self {
|
||||
DataRow::new(row)
|
||||
}
|
||||
}
|
||||
|
||||
///// Columns /////
|
||||
/// Data column position in CSV table. 0-based
|
||||
///
|
||||
/// Currently represents both display and data coordinate systems since
|
||||
/// column reordering is not yet implemented. When column reordering is added,
|
||||
/// this will need to be split into `DisplayColumn` and `DataColumn` types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct AnyColumn(pub usize);
|
||||
|
||||
impl AnyColumn {
|
||||
/// Create a new column ID
|
||||
pub fn new(col: usize) -> Self {
|
||||
Self(col)
|
||||
}
|
||||
|
||||
/// Get the inner column value
|
||||
pub fn get(self) -> usize {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for AnyColumn {
|
||||
type Target = usize;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for AnyColumn {
|
||||
fn from(col: usize) -> Self {
|
||||
AnyColumn::new(col)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AnyColumn> for usize {
|
||||
fn from(value: AnyColumn) -> Self {
|
||||
*value
|
||||
}
|
||||
}
|
||||
|
||||
///// Cells /////
|
||||
/// Visual cell position in rendered table.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DisplayCellId {
|
||||
pub row: DisplayRow,
|
||||
pub col: AnyColumn,
|
||||
}
|
||||
|
||||
impl DisplayCellId {
|
||||
/// Create a new display cell ID
|
||||
pub fn new(row: impl Into<DisplayRow>, col: impl Into<AnyColumn>) -> Self {
|
||||
Self {
|
||||
row: row.into(),
|
||||
col: col.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (row, column)
|
||||
pub fn to_raw(&self) -> (usize, usize) {
|
||||
(self.row.0, self.col.0)
|
||||
}
|
||||
}
|
||||
54
crates/csv_preview/src/types/table_cell.rs
Normal file
54
crates/csv_preview/src/types/table_cell.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use text::Anchor;
|
||||
use ui::SharedString;
|
||||
|
||||
/// Position of a cell within the source CSV buffer
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CellContentSpan {
|
||||
/// Start anchor of the cell content in the source buffer
|
||||
pub start: Anchor,
|
||||
/// End anchor of the cell content in the source buffer
|
||||
pub end: Anchor,
|
||||
}
|
||||
|
||||
/// A table cell with its content and position in the source buffer
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TableCell {
|
||||
/// Cell existing in the CSV
|
||||
Real {
|
||||
/// Position of this cell in the source buffer
|
||||
position: CellContentSpan,
|
||||
/// Cached display value (for performance)
|
||||
cached_value: SharedString,
|
||||
},
|
||||
/// Virtual cell, created to pad malformed row
|
||||
Virtual,
|
||||
}
|
||||
|
||||
impl TableCell {
|
||||
/// Create a TableCell with buffer position tracking
|
||||
pub fn from_buffer_position(
|
||||
content: SharedString,
|
||||
start_offset: usize,
|
||||
end_offset: usize,
|
||||
buffer_snapshot: &text::BufferSnapshot,
|
||||
) -> Self {
|
||||
let start_anchor = buffer_snapshot.anchor_before(start_offset);
|
||||
let end_anchor = buffer_snapshot.anchor_after(end_offset);
|
||||
|
||||
Self::Real {
|
||||
position: CellContentSpan {
|
||||
start: start_anchor,
|
||||
end: end_anchor,
|
||||
},
|
||||
cached_value: content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display value for this cell
|
||||
pub fn display_value(&self) -> Option<&SharedString> {
|
||||
match self {
|
||||
TableCell::Real { cached_value, .. } => Some(cached_value),
|
||||
TableCell::Virtual => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
32
crates/csv_preview/src/types/table_like_content.rs
Normal file
32
crates/csv_preview/src/types/table_like_content.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use ui::table_row::TableRow;
|
||||
|
||||
use crate::types::{DataRow, LineNumber, TableCell};
|
||||
|
||||
/// Generic container struct of table-like data (CSV, TSV, etc)
|
||||
#[derive(Clone)]
|
||||
pub struct TableLikeContent {
|
||||
/// Number of data columns.
|
||||
/// Defines table width used to validate `TableRow` on creation
|
||||
pub number_of_cols: usize,
|
||||
pub headers: TableRow<TableCell>,
|
||||
pub rows: Vec<TableRow<TableCell>>,
|
||||
/// Follows the same indices as `rows`
|
||||
pub line_numbers: Vec<LineNumber>,
|
||||
}
|
||||
|
||||
impl Default for TableLikeContent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
number_of_cols: 0,
|
||||
headers: TableRow::<TableCell>::from_vec(vec![], 0),
|
||||
rows: vec![],
|
||||
line_numbers: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TableLikeContent {
|
||||
pub(crate) fn get_row(&self, data_row: DataRow) -> Option<&TableRow<TableCell>> {
|
||||
self.rows.get(*data_row)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user