mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
Clean up TUI package and refactor component structure
- Remove old TUI implementation and components (LoadingAnimation, MarkdownComponent, TextComponent, TextEditor, WhitespaceComponent) - Rename components-new to components with new API (Loader, Markdown, Text, Editor, Spacer) - Move Text and Input components to separate files in src/components/ - Add render caching to Text component (similar to Markdown) - Add proper ANSI code handling in Text component using stripVTControlCharacters - Update coding-agent to use new TUI API (requires ProcessTerminal, uses custom Editor subclass for key handling) - Remove old test files, keep only chat-simple.ts and virtual-terminal.ts - Update README.md with new minimal API documentation - Switch from tsc to tsgo for type checking - Update package dependencies across monorepo
This commit is contained in:
parent
1caa3cc1a7
commit
985f955ea0
40 changed files with 998 additions and 4516 deletions
|
|
@ -1,483 +1,228 @@
|
|||
import process from "process";
|
||||
import { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||
|
||||
/**
|
||||
* Result of rendering a component
|
||||
* Minimal TUI implementation with differential rendering
|
||||
*/
|
||||
export interface ComponentRenderResult {
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
import type { Terminal } from "./terminal.js";
|
||||
|
||||
/**
|
||||
* Component interface
|
||||
* Component interface - all components must implement this
|
||||
*/
|
||||
export interface Component {
|
||||
readonly id: number;
|
||||
render(width: number): ComponentRenderResult;
|
||||
handleInput?(keyData: string): void;
|
||||
}
|
||||
/**
|
||||
* Render the component to lines for the given viewport width
|
||||
* @param width - Current viewport width
|
||||
* @returns Array of strings, each representing a line
|
||||
*/
|
||||
render(width: number): string[];
|
||||
|
||||
// Global component ID counter
|
||||
let nextComponentId = 1;
|
||||
|
||||
// Helper to get next component ID
|
||||
export function getNextComponentId(): number {
|
||||
return nextComponentId++;
|
||||
}
|
||||
|
||||
// Padding type for components
|
||||
export interface Padding {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
/**
|
||||
* Optional handler for keyboard input when component has focus
|
||||
*/
|
||||
handleInput?(data: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for managing child components
|
||||
* Container - a component that contains other components
|
||||
*/
|
||||
export class Container implements Component {
|
||||
readonly id: number;
|
||||
public children: (Component | Container)[] = [];
|
||||
private tui?: TUI;
|
||||
private previousChildCount: number = 0;
|
||||
children: Component[] = [];
|
||||
|
||||
constructor() {
|
||||
this.id = getNextComponentId();
|
||||
}
|
||||
|
||||
setTui(tui: TUI | undefined): void {
|
||||
this.tui = tui;
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setTui(tui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addChild(component: Component | Container): void {
|
||||
addChild(component: Component): void {
|
||||
this.children.push(component);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(this.tui);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
|
||||
removeChild(component: Component | Container): void {
|
||||
removeChild(component: Component): void {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index >= 0) {
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(undefined);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
removeChildAt(index: number): void {
|
||||
if (index >= 0 && index < this.children.length) {
|
||||
const component = this.children[index];
|
||||
this.children.splice(index, 1);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(undefined);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setTui(undefined);
|
||||
}
|
||||
}
|
||||
this.children = [];
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
|
||||
getChild(index: number): (Component | Container) | undefined {
|
||||
return this.children[index];
|
||||
}
|
||||
|
||||
getChildCount(): number {
|
||||
return this.children.length;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
// Check if the number of children changed (important for detecting clears)
|
||||
if (this.children.length !== this.previousChildCount) {
|
||||
changed = true;
|
||||
this.previousChildCount = this.children.length;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
const result = child.render(width);
|
||||
lines.push(...result.lines);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
}
|
||||
lines.push(...child.render(width));
|
||||
}
|
||||
|
||||
return { lines, changed };
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render command for tracking component output
|
||||
*/
|
||||
interface RenderCommand {
|
||||
id: number;
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* TUI - Smart differential rendering TUI implementation.
|
||||
* TUI - Main class for managing terminal UI with differential rendering
|
||||
*/
|
||||
export class TUI extends Container {
|
||||
private focusedComponent: Component | null = null;
|
||||
private needsRender = false;
|
||||
private isFirstRender = true;
|
||||
private isStarted = false;
|
||||
public onGlobalKeyPress?: (data: string) => boolean;
|
||||
private terminal: Terminal;
|
||||
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used in renderToScreen method on lines 260 and 276
|
||||
private previousRenderCommands: RenderCommand[] = [];
|
||||
private previousLines: string[] = []; // What we rendered last time
|
||||
private previousLines: string[] = [];
|
||||
private previousWidth = 0;
|
||||
private focusedComponent: Component | null = null;
|
||||
private renderRequested = false;
|
||||
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
|
||||
|
||||
// Performance metrics
|
||||
private totalLinesRedrawn = 0;
|
||||
private renderCount = 0;
|
||||
public getLinesRedrawn(): number {
|
||||
return this.totalLinesRedrawn;
|
||||
}
|
||||
public getAverageLinesRedrawn(): number {
|
||||
return this.renderCount > 0 ? this.totalLinesRedrawn / this.renderCount : 0;
|
||||
}
|
||||
|
||||
constructor(terminal?: Terminal) {
|
||||
constructor(terminal: Terminal) {
|
||||
super();
|
||||
this.setTui(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleKeypress = this.handleKeypress.bind(this);
|
||||
|
||||
// Use provided terminal or default to ProcessTerminal
|
||||
this.terminal = terminal || new ProcessTerminal();
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
setFocus(component: Component): void {
|
||||
if (this.findComponent(component)) {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
}
|
||||
|
||||
private findComponent(component: Component): boolean {
|
||||
if (this.children.includes(component)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private findInContainer(container: Container, component: Component): boolean {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (child === component) {
|
||||
return true;
|
||||
}
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
requestRender(): void {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
// Only queue a render if we haven't already
|
||||
if (!this.needsRender) {
|
||||
this.needsRender = true;
|
||||
process.nextTick(() => {
|
||||
if (this.needsRender) {
|
||||
this.renderToScreen();
|
||||
this.needsRender = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
setFocus(component: Component | null): void {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.isStarted = true;
|
||||
|
||||
// Hide cursor
|
||||
this.terminal.write("\x1b[?25l");
|
||||
|
||||
// Start terminal with handlers
|
||||
try {
|
||||
this.terminal.start(this.handleKeypress, this.handleResize);
|
||||
} catch (error) {
|
||||
console.error("Error starting terminal:", error);
|
||||
}
|
||||
|
||||
// Trigger initial render if we have components
|
||||
if (this.children.length > 0) {
|
||||
this.requestRender();
|
||||
}
|
||||
this.terminal.start(
|
||||
(data) => this.handleInput(data),
|
||||
() => this.requestRender(),
|
||||
);
|
||||
this.terminal.hideCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Show cursor
|
||||
this.terminal.write("\x1b[?25h");
|
||||
|
||||
// Stop terminal
|
||||
this.terminal.showCursor();
|
||||
this.terminal.stop();
|
||||
|
||||
this.isStarted = false;
|
||||
}
|
||||
|
||||
private renderToScreen(resize = false): void {
|
||||
const termWidth = this.terminal.columns;
|
||||
const termHeight = this.terminal.rows;
|
||||
|
||||
if (resize) {
|
||||
this.isFirstRender = true;
|
||||
this.previousRenderCommands = [];
|
||||
this.previousLines = [];
|
||||
}
|
||||
|
||||
// Collect all render commands
|
||||
const currentRenderCommands: RenderCommand[] = [];
|
||||
this.collectRenderCommands(this, termWidth, currentRenderCommands);
|
||||
|
||||
if (this.isFirstRender) {
|
||||
this.renderInitial(currentRenderCommands);
|
||||
this.isFirstRender = false;
|
||||
} else {
|
||||
this.renderLineBased(currentRenderCommands, termHeight);
|
||||
}
|
||||
|
||||
// Save for next render
|
||||
this.previousRenderCommands = currentRenderCommands;
|
||||
this.renderCount++;
|
||||
requestRender(): void {
|
||||
if (this.renderRequested) return;
|
||||
this.renderRequested = true;
|
||||
process.nextTick(() => {
|
||||
this.renderRequested = false;
|
||||
this.doRender();
|
||||
});
|
||||
}
|
||||
|
||||
private collectRenderCommands(container: Container, width: number, commands: RenderCommand[]): void {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
const result = child.render(width);
|
||||
commands.push({
|
||||
id: child.id,
|
||||
lines: result.lines,
|
||||
changed: result.changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderInitial(commands: RenderCommand[]): void {
|
||||
let output = "";
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
lines.push(...command.lines);
|
||||
}
|
||||
|
||||
// Output all lines
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i > 0) output += "\r\n";
|
||||
output += lines[i];
|
||||
}
|
||||
|
||||
// Add final newline to position cursor below content
|
||||
if (lines.length > 0) output += "\r\n";
|
||||
|
||||
this.terminal.write(output);
|
||||
|
||||
// Save what we rendered
|
||||
this.previousLines = lines;
|
||||
this.totalLinesRedrawn += lines.length;
|
||||
}
|
||||
|
||||
private renderLineBased(currentCommands: RenderCommand[], termHeight: number): void {
|
||||
const viewportHeight = termHeight - 1; // Leave one line for cursor
|
||||
|
||||
// Build the new lines array
|
||||
const newLines: string[] = [];
|
||||
for (const command of currentCommands) {
|
||||
newLines.push(...command.lines);
|
||||
}
|
||||
|
||||
const totalNewLines = newLines.length;
|
||||
const totalOldLines = this.previousLines.length;
|
||||
|
||||
// Find first changed line by comparing old and new
|
||||
let firstChangedLine = -1;
|
||||
const minLines = Math.min(totalOldLines, totalNewLines);
|
||||
|
||||
for (let i = 0; i < minLines; i++) {
|
||||
if (this.previousLines[i] !== newLines[i]) {
|
||||
firstChangedLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If all common lines are the same, check if we have different lengths
|
||||
if (firstChangedLine === -1 && totalOldLines !== totalNewLines) {
|
||||
firstChangedLine = minLines;
|
||||
}
|
||||
|
||||
// No changes at all
|
||||
if (firstChangedLine === -1) {
|
||||
this.previousLines = newLines;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate viewport boundaries
|
||||
const oldViewportStart = Math.max(0, totalOldLines - viewportHeight);
|
||||
const cursorPosition = totalOldLines; // Cursor is one line below last content
|
||||
|
||||
let output = "";
|
||||
let linesRedrawn = 0;
|
||||
|
||||
// Check if change is in scrollback (unreachable by cursor)
|
||||
if (firstChangedLine < oldViewportStart) {
|
||||
// Must do full clear and re-render
|
||||
output = "\x1b[3J\x1b[H"; // Clear scrollback and screen, home cursor
|
||||
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) output += "\r\n";
|
||||
output += newLines[i];
|
||||
}
|
||||
|
||||
if (newLines.length > 0) output += "\r\n";
|
||||
linesRedrawn = newLines.length;
|
||||
} else {
|
||||
// Change is in viewport - we can reach it with cursor movements
|
||||
// Calculate viewport position of the change
|
||||
const viewportChangePosition = firstChangedLine - oldViewportStart;
|
||||
|
||||
// Move cursor to the change position
|
||||
const linesToMoveUp = cursorPosition - oldViewportStart - viewportChangePosition;
|
||||
if (linesToMoveUp > 0) {
|
||||
output += `\x1b[${linesToMoveUp}A`;
|
||||
}
|
||||
|
||||
// Now do surgical updates or partial clear based on what's more efficient
|
||||
let currentLine = firstChangedLine;
|
||||
const currentViewportLine = viewportChangePosition;
|
||||
|
||||
// If we have significant structural changes, just clear and re-render from here
|
||||
const hasSignificantChanges = totalNewLines !== totalOldLines || totalNewLines - firstChangedLine > 10; // Arbitrary threshold
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
// Clear from cursor to end of screen and render all remaining lines
|
||||
output += "\r\x1b[0J";
|
||||
|
||||
for (let i = firstChangedLine; i < newLines.length; i++) {
|
||||
if (i > firstChangedLine) output += "\r\n";
|
||||
output += newLines[i];
|
||||
linesRedrawn++;
|
||||
}
|
||||
|
||||
if (newLines.length > firstChangedLine) output += "\r\n";
|
||||
} else {
|
||||
// Do surgical line-by-line updates
|
||||
for (let i = firstChangedLine; i < minLines; i++) {
|
||||
if (this.previousLines[i] !== newLines[i]) {
|
||||
// Move to this line if needed
|
||||
const moveLines = i - currentLine;
|
||||
if (moveLines > 0) {
|
||||
output += `\x1b[${moveLines}B`;
|
||||
}
|
||||
|
||||
// Clear and rewrite the line
|
||||
output += "\r\x1b[2K" + newLines[i];
|
||||
currentLine = i;
|
||||
linesRedrawn++;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle added/removed lines at the end
|
||||
if (totalNewLines > totalOldLines) {
|
||||
// Move to end of old content and add new lines
|
||||
const moveToEnd = totalOldLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
}
|
||||
output += "\r\n";
|
||||
|
||||
for (let i = totalOldLines; i < totalNewLines; i++) {
|
||||
if (i > totalOldLines) output += "\r\n";
|
||||
output += newLines[i];
|
||||
linesRedrawn++;
|
||||
}
|
||||
output += "\r\n";
|
||||
} else if (totalNewLines < totalOldLines) {
|
||||
// Move to end of new content and clear rest
|
||||
const moveToEnd = totalNewLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
} else if (moveToEnd < 0) {
|
||||
output += `\x1b[${-moveToEnd}A`;
|
||||
}
|
||||
output += "\r\n\x1b[0J";
|
||||
} else {
|
||||
// Same length, just position cursor at end
|
||||
const moveToEnd = totalNewLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
} else if (moveToEnd < 0) {
|
||||
output += `\x1b[${-moveToEnd}A`;
|
||||
}
|
||||
output += "\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.terminal.write(output);
|
||||
this.previousLines = newLines;
|
||||
this.totalLinesRedrawn += linesRedrawn;
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
// Clear screen and reset
|
||||
this.terminal.write("\x1b[2J\x1b[H\x1b[?25l");
|
||||
this.renderToScreen(true);
|
||||
}
|
||||
|
||||
private handleKeypress(data: string): void {
|
||||
if (this.onGlobalKeyPress) {
|
||||
const shouldForward = this.onGlobalKeyPress(data);
|
||||
if (!shouldForward) {
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
private handleInput(data: string): void {
|
||||
// Exit on Ctrl+C
|
||||
if (data === "\x03") {
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Pass input to focused component
|
||||
if (this.focusedComponent?.handleInput) {
|
||||
this.focusedComponent.handleInput(data);
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
private doRender(): void {
|
||||
const width = this.terminal.columns;
|
||||
const height = this.terminal.rows;
|
||||
|
||||
// Render all components to get new lines
|
||||
const newLines = this.render(width);
|
||||
|
||||
// Width changed - need full re-render
|
||||
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
||||
|
||||
// First render - just output everything without clearing
|
||||
if (this.previousLines.length === 0) {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
// After rendering N lines, cursor is at end of last line (line N-1)
|
||||
this.cursorRow = newLines.length - 1;
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
return;
|
||||
}
|
||||
|
||||
// Width changed - full re-render
|
||||
if (widthChanged) {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
buffer += "\x1b[2J\x1b[H"; // Clear screen and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = newLines.length - 1;
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find first and last changed lines
|
||||
let firstChanged = -1;
|
||||
let lastChanged = -1;
|
||||
|
||||
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
||||
const newLine = i < newLines.length ? newLines[i] : "";
|
||||
|
||||
if (oldLine !== newLine) {
|
||||
if (firstChanged === -1) {
|
||||
firstChanged = i;
|
||||
}
|
||||
lastChanged = i;
|
||||
}
|
||||
}
|
||||
|
||||
// No changes
|
||||
if (firstChanged === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if firstChanged is outside the viewport
|
||||
// cursorRow is the line where cursor is (0-indexed)
|
||||
// Viewport shows lines from (cursorRow - height + 1) to cursorRow
|
||||
// If firstChanged < viewportTop, we need full re-render
|
||||
const viewportTop = this.cursorRow - height + 1;
|
||||
if (firstChanged < viewportTop) {
|
||||
// First change is above viewport - need full re-render
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
buffer += "\x1b[2J\x1b[H"; // Clear screen and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = newLines.length - 1;
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render from first changed line to end
|
||||
// Build buffer with all updates wrapped in synchronized output
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
|
||||
// Move cursor to first changed line
|
||||
const lineDiff = firstChanged - this.cursorRow;
|
||||
if (lineDiff > 0) {
|
||||
buffer += `\x1b[${lineDiff}B`; // Move down
|
||||
} else if (lineDiff < 0) {
|
||||
buffer += `\x1b[${-lineDiff}A`; // Move up
|
||||
}
|
||||
|
||||
buffer += "\r"; // Move to column 0
|
||||
buffer += "\x1b[J"; // Clear from cursor to end of screen
|
||||
|
||||
// Render from first changed line to end
|
||||
for (let i = firstChanged; i < newLines.length; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
|
||||
// Write entire buffer at once
|
||||
this.terminal.write(buffer);
|
||||
|
||||
// Cursor is now at end of last line
|
||||
this.cursorRow = newLines.length - 1;
|
||||
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue