logloglog 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logloglog-0.0.1/LICENSE.md +7 -0
- logloglog-0.0.1/PKG-INFO +73 -0
- logloglog-0.0.1/README.md +36 -0
- logloglog-0.0.1/pyproject.toml +60 -0
- logloglog-0.0.1/src/logloglog/__init__.py +40 -0
- logloglog-0.0.1/src/logloglog/__main__.py +8 -0
- logloglog-0.0.1/src/logloglog/cache.py +85 -0
- logloglog-0.0.1/src/logloglog/line_index.py +274 -0
- logloglog-0.0.1/src/logloglog/log_file.py +187 -0
- logloglog-0.0.1/src/logloglog/logloglog.py +647 -0
- logloglog-0.0.1/src/logloglog/tools/__init__.py +1 -0
- logloglog-0.0.1/src/logloglog/tools/stream_logs.py +317 -0
- logloglog-0.0.1/src/logloglog/ui/__init__.py +0 -0
- logloglog-0.0.1/src/logloglog/ui/textual/__init__.py +3 -0
- logloglog-0.0.1/src/logloglog/ui/textual/log_widget.py +298 -0
- logloglog-0.0.1/src/logloglog/widthview.py +98 -0
logloglog-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logloglog
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Efficient scrollback indexing for large log files with terminal word wrapping
|
|
5
|
+
Keywords: logging,terminal,scrollback,indexing
|
|
6
|
+
Author-email: Gareth Davidson <gaz@bitplane.net>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: Public Domain
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Classifier: Topic :: Terminals
|
|
19
|
+
License-File: LICENSE.md
|
|
20
|
+
Requires-Dist: wcwidth~=0.2.5
|
|
21
|
+
Requires-Dist: platformdirs~=3.0
|
|
22
|
+
Requires-Dist: arrayfile~=0.0.1
|
|
23
|
+
Requires-Dist: logloglog[textual] ; extra == "dev"
|
|
24
|
+
Requires-Dist: pre-commit ; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest ; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio ; extra == "dev"
|
|
27
|
+
Requires-Dist: coverage ; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov ; extra == "dev"
|
|
29
|
+
Requires-Dist: build ; extra == "dev"
|
|
30
|
+
Requires-Dist: twine ; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff ; extra == "dev"
|
|
32
|
+
Requires-Dist: textual ; extra == "textual"
|
|
33
|
+
Requires-Dist: textual-window ; extra == "textual"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Provides-Extra: textual
|
|
36
|
+
|
|
37
|
+
# 🪵🪵🪵
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
> What rolls down stairs, alone or in pairs, and over your neighbor's dog?
|
|
42
|
+
> What's great for a snack, And fits on your back?
|
|
43
|
+
> It's Log Log Log!
|
|
44
|
+
|
|
45
|
+
I needed line wrapping in my tty with fast access, so why not make a log that
|
|
46
|
+
supports egregious sizes and low seek times?
|
|
47
|
+
|
|
48
|
+
## ⏩
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# makes log.log.log from /var/log
|
|
52
|
+
make log
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## TODO
|
|
56
|
+
|
|
57
|
+
- [ ] Textual demo
|
|
58
|
+
- [x] stats in window
|
|
59
|
+
- [ ] slim demo down
|
|
60
|
+
- [x] follow last line when at end
|
|
61
|
+
- [x] Async/non-blocking design
|
|
62
|
+
- [x] Make log processing async to avoid blocking
|
|
63
|
+
- [x] Support streaming updates
|
|
64
|
+
- [ ] Multiple display backends
|
|
65
|
+
- [ ] Python logging integration
|
|
66
|
+
- [ ] Direct logger handlers
|
|
67
|
+
- [ ] Log level filtering and formatting
|
|
68
|
+
- [ ] Cache management
|
|
69
|
+
- [ ] Periodic cleanup of cache directories
|
|
70
|
+
- [ ] Check file existence by inode lookup
|
|
71
|
+
- [ ] Handle file rotation and moves
|
|
72
|
+
|
|
73
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# 🪵🪵🪵
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
> What rolls down stairs, alone or in pairs, and over your neighbor's dog?
|
|
6
|
+
> What's great for a snack, And fits on your back?
|
|
7
|
+
> It's Log Log Log!
|
|
8
|
+
|
|
9
|
+
I needed line wrapping in my tty with fast access, so why not make a log that
|
|
10
|
+
supports egregious sizes and low seek times?
|
|
11
|
+
|
|
12
|
+
## ⏩
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# makes log.log.log from /var/log
|
|
16
|
+
make log
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## TODO
|
|
20
|
+
|
|
21
|
+
- [ ] Textual demo
|
|
22
|
+
- [x] stats in window
|
|
23
|
+
- [ ] slim demo down
|
|
24
|
+
- [x] follow last line when at end
|
|
25
|
+
- [x] Async/non-blocking design
|
|
26
|
+
- [x] Make log processing async to avoid blocking
|
|
27
|
+
- [x] Support streaming updates
|
|
28
|
+
- [ ] Multiple display backends
|
|
29
|
+
- [ ] Python logging integration
|
|
30
|
+
- [ ] Direct logger handlers
|
|
31
|
+
- [ ] Log level filtering and formatting
|
|
32
|
+
- [ ] Cache management
|
|
33
|
+
- [ ] Periodic cleanup of cache directories
|
|
34
|
+
- [ ] Check file existence by inode lookup
|
|
35
|
+
- [ ] Handle file rotation and moves
|
|
36
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "logloglog"
|
|
3
|
+
description = "Efficient scrollback indexing for large log files with terminal word wrapping"
|
|
4
|
+
version = "0.0.1"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Gareth Davidson", email = "gaz@bitplane.net" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = {text = "WTFPL"}
|
|
11
|
+
keywords = ["logging", "terminal", "scrollback", "indexing"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: Public Domain",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: System :: Logging",
|
|
22
|
+
"Topic :: Terminals",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"wcwidth~=0.2.5",
|
|
27
|
+
"platformdirs~=3.0",
|
|
28
|
+
"arrayfile~=0.0.1"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"logloglog[textual]",
|
|
34
|
+
"pre-commit",
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-asyncio",
|
|
37
|
+
"coverage",
|
|
38
|
+
"pytest-cov",
|
|
39
|
+
"build",
|
|
40
|
+
"twine",
|
|
41
|
+
"ruff"
|
|
42
|
+
]
|
|
43
|
+
textual = [
|
|
44
|
+
"textual",
|
|
45
|
+
"textual-window"
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
stream-logs = "logloglog.tools.stream_logs:main"
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
build-backend = "flit_core.buildapi"
|
|
53
|
+
requires = ["flit_core >=3.2,<4"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 120
|
|
57
|
+
target-version = "py310"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.format]
|
|
60
|
+
docstring-code-format = true
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""LogLogLog - Efficient scrollback indexing for large log files."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from .logloglog import LogLogLog
|
|
9
|
+
from .widthview import WidthView
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("logloglog")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
# Package is not installed
|
|
15
|
+
__version__ = "0.0.0+dev"
|
|
16
|
+
|
|
17
|
+
__all__ = ["LogLogLog", "WidthView", "configure_logging"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Configure logging for LogLogLog
|
|
21
|
+
def configure_logging(level=logging.INFO):
|
|
22
|
+
"""Configure logging for LogLogLog."""
|
|
23
|
+
# Create formatter
|
|
24
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
25
|
+
|
|
26
|
+
# Create console handler
|
|
27
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
28
|
+
handler.setFormatter(formatter)
|
|
29
|
+
|
|
30
|
+
# Configure logloglog loggers
|
|
31
|
+
for logger_name in ["logloglog.logloglog", "logloglog.wraptree", "logloglog.index"]:
|
|
32
|
+
logger = logging.getLogger(logger_name)
|
|
33
|
+
logger.setLevel(level)
|
|
34
|
+
# Remove existing handlers to avoid duplicates
|
|
35
|
+
logger.handlers.clear()
|
|
36
|
+
logger.addHandler(handler)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Auto-configure with DEBUG level for performance monitoring
|
|
40
|
+
configure_logging(logging.DEBUG)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Cache management for LogLogLog."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import platformdirs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Cache constants
|
|
12
|
+
CACHE_DIR = Path(platformdirs.user_cache_dir("logloglog"))
|
|
13
|
+
TMP_DIR = Path(tempfile.gettempdir()) / "logloglog"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Cache:
|
|
17
|
+
"""Manages cache directories for log files."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cache_dir: Path = None):
|
|
20
|
+
"""Initialize cache manager.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
cache_dir: Cache directory (defaults to CACHE_DIR)
|
|
24
|
+
"""
|
|
25
|
+
self.cache_dir = cache_dir or CACHE_DIR
|
|
26
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def get_dir(self, path: Path) -> Path:
|
|
29
|
+
"""Get cache directory for a log file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Path to the log file
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Cache directory path
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
OSError: If file cannot be accessed or cache directory cannot be created
|
|
39
|
+
"""
|
|
40
|
+
# Get file stats for unique identification
|
|
41
|
+
stat = os.stat(path)
|
|
42
|
+
# Create hash from device and inode only (removed ctime for stability)
|
|
43
|
+
hash_input = f"{stat.st_dev}_{stat.st_ino}"
|
|
44
|
+
hash_digest = hashlib.md5(hash_input.encode()).hexdigest()[:8]
|
|
45
|
+
|
|
46
|
+
# Create cache directory name
|
|
47
|
+
name = path.name
|
|
48
|
+
cache_name = f"{name}[{hash_digest}]"
|
|
49
|
+
cache_path = self.cache_dir / cache_name
|
|
50
|
+
|
|
51
|
+
# Create directory if it doesn't exist
|
|
52
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
# Create symlink to original file for reference
|
|
55
|
+
symlink_path = cache_path / "file"
|
|
56
|
+
if not symlink_path.exists():
|
|
57
|
+
symlink_path.symlink_to(path.resolve())
|
|
58
|
+
|
|
59
|
+
return cache_path
|
|
60
|
+
|
|
61
|
+
def cleanup(self):
|
|
62
|
+
"""Clean up cache directories for files that no longer exist."""
|
|
63
|
+
if not self.cache_dir.exists():
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
for cache_subdir in self.cache_dir.iterdir():
|
|
67
|
+
if not cache_subdir.is_dir():
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# Check if the symlink exists and points to a valid file
|
|
71
|
+
symlink_path = cache_subdir / "file"
|
|
72
|
+
if symlink_path.exists():
|
|
73
|
+
try:
|
|
74
|
+
# Try to resolve the symlink
|
|
75
|
+
target = symlink_path.resolve()
|
|
76
|
+
if not target.exists():
|
|
77
|
+
# Original file is gone, remove cache directory
|
|
78
|
+
shutil.rmtree(cache_subdir)
|
|
79
|
+
except (OSError, FileNotFoundError):
|
|
80
|
+
# Symlink is broken, remove cache directory
|
|
81
|
+
shutil.rmtree(cache_subdir)
|
|
82
|
+
else:
|
|
83
|
+
# No symlink found, directory is orphaned
|
|
84
|
+
shutil.rmtree(cache_subdir)
|
|
85
|
+
# TODO: Add inode-based cleanup for more robust file tracking
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Simple line indexing with periodic summaries for efficient wrapping calculations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
from arrayfile import Array
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Configuration
|
|
11
|
+
MAX_WIDTH = 512 # Maximum terminal width we support
|
|
12
|
+
SUMMARY_INTERVAL = 1000 # Store summary every N lines
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LineIndex:
|
|
16
|
+
"""
|
|
17
|
+
Indexes log lines with byte positions, widths, and periodic summaries.
|
|
18
|
+
|
|
19
|
+
Stores:
|
|
20
|
+
- line_positions: byte offset of each line in the log file
|
|
21
|
+
- line_widths: display width of each line
|
|
22
|
+
- summaries: every 1000 lines, cumulative display rows for each width 1-512
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, index_path: Path):
|
|
26
|
+
"""Initialize line index with given path."""
|
|
27
|
+
self.index_path = index_path
|
|
28
|
+
self._line_positions = None
|
|
29
|
+
self._line_widths = None
|
|
30
|
+
self._summaries = None
|
|
31
|
+
self._line_count = 0
|
|
32
|
+
self._current_block_width_counts = {} # Track widths in current 1000-line block
|
|
33
|
+
self._pending_positions = [] # Batch positions for extend()
|
|
34
|
+
self._pending_widths = [] # Batch widths for extend()
|
|
35
|
+
|
|
36
|
+
def open(self, create: bool = False):
|
|
37
|
+
"""Open index files."""
|
|
38
|
+
self.index_path.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
mode = "w+b" if create else "r+b"
|
|
41
|
+
|
|
42
|
+
# Line positions (uint64 for file offsets)
|
|
43
|
+
self._line_positions = Array("Q", str(self.index_path / "positions.dat"), mode)
|
|
44
|
+
|
|
45
|
+
# Line widths (uint16, capped at 65535)
|
|
46
|
+
self._line_widths = Array("H", str(self.index_path / "widths.dat"), mode)
|
|
47
|
+
|
|
48
|
+
# Summaries (uint32 array, MAX_WIDTH entries per summary)
|
|
49
|
+
self._summaries = Array("I", str(self.index_path / "summaries.dat"), mode)
|
|
50
|
+
|
|
51
|
+
# Count existing lines
|
|
52
|
+
self._line_count = len(self._line_positions)
|
|
53
|
+
|
|
54
|
+
def close(self):
|
|
55
|
+
"""Close all index files."""
|
|
56
|
+
# Flush any pending data before closing
|
|
57
|
+
self._flush_pending()
|
|
58
|
+
|
|
59
|
+
if self._line_positions:
|
|
60
|
+
self._line_positions.close()
|
|
61
|
+
self._line_positions = None
|
|
62
|
+
if self._line_widths:
|
|
63
|
+
self._line_widths.close()
|
|
64
|
+
self._line_widths = None
|
|
65
|
+
if self._summaries:
|
|
66
|
+
self._summaries.close()
|
|
67
|
+
self._summaries = None
|
|
68
|
+
|
|
69
|
+
def append_line(self, position: int, width: int):
|
|
70
|
+
"""
|
|
71
|
+
Append a new line to the index.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
position: Byte offset of line start in log file
|
|
75
|
+
width: Display width of the line
|
|
76
|
+
"""
|
|
77
|
+
# Cap width at uint16 max
|
|
78
|
+
width = min(width, 65535)
|
|
79
|
+
|
|
80
|
+
# Batch in memory
|
|
81
|
+
self._pending_positions.append(position)
|
|
82
|
+
self._pending_widths.append(width)
|
|
83
|
+
self._line_count += 1
|
|
84
|
+
|
|
85
|
+
# Track width for current block
|
|
86
|
+
self._current_block_width_counts[width] = self._current_block_width_counts.get(width, 0) + 1
|
|
87
|
+
|
|
88
|
+
# Check if we need to flush and store a summary
|
|
89
|
+
if self._line_count % SUMMARY_INTERVAL == 0:
|
|
90
|
+
self._flush_pending()
|
|
91
|
+
self._store_summary()
|
|
92
|
+
self._current_block_width_counts.clear()
|
|
93
|
+
|
|
94
|
+
def _flush_pending(self):
|
|
95
|
+
"""Flush pending positions and widths to disk."""
|
|
96
|
+
if self._pending_positions:
|
|
97
|
+
self._line_positions.extend(self._pending_positions)
|
|
98
|
+
self._pending_positions.clear()
|
|
99
|
+
if self._pending_widths:
|
|
100
|
+
self._line_widths.extend(self._pending_widths)
|
|
101
|
+
self._pending_widths.clear()
|
|
102
|
+
|
|
103
|
+
def _store_summary(self):
|
|
104
|
+
"""Store summary using already-tracked width counts."""
|
|
105
|
+
# Calculate totals for each terminal width
|
|
106
|
+
width_totals = [0] * MAX_WIDTH
|
|
107
|
+
for line_width, count in self._current_block_width_counts.items():
|
|
108
|
+
if line_width == 0:
|
|
109
|
+
# Empty lines always take 1 row regardless of terminal width
|
|
110
|
+
for i in range(MAX_WIDTH):
|
|
111
|
+
width_totals[i] += count
|
|
112
|
+
else:
|
|
113
|
+
# Calculate rows for each terminal width
|
|
114
|
+
# Ceiling division: (line_width + term_width - 1) // term_width
|
|
115
|
+
# This is always >= 1 when both operands are positive
|
|
116
|
+
for term_width in range(1, MAX_WIDTH + 1):
|
|
117
|
+
rows = (line_width + term_width - 1) // term_width
|
|
118
|
+
width_totals[term_width - 1] += rows * count
|
|
119
|
+
|
|
120
|
+
# Store all width totals in summary array (batch append for performance)
|
|
121
|
+
self._summaries.extend(width_totals)
|
|
122
|
+
|
|
123
|
+
def get_line_position(self, line_no: int) -> int:
|
|
124
|
+
"""Get byte position of a line."""
|
|
125
|
+
if line_no < 0 or line_no >= self._line_count:
|
|
126
|
+
raise IndexError(f"Line {line_no} out of range")
|
|
127
|
+
|
|
128
|
+
# Check if it's in the flushed data or pending batch
|
|
129
|
+
flushed_count = len(self._line_positions)
|
|
130
|
+
if line_no < flushed_count:
|
|
131
|
+
return self._line_positions[line_no]
|
|
132
|
+
else:
|
|
133
|
+
# It's in the pending batch
|
|
134
|
+
pending_idx = line_no - flushed_count
|
|
135
|
+
return self._pending_positions[pending_idx]
|
|
136
|
+
|
|
137
|
+
def get_line_width(self, line_no: int) -> int:
|
|
138
|
+
"""Get display width of a line."""
|
|
139
|
+
if line_no < 0 or line_no >= self._line_count:
|
|
140
|
+
raise IndexError(f"Line {line_no} out of range")
|
|
141
|
+
|
|
142
|
+
# Check if it's in the flushed data or pending batch
|
|
143
|
+
flushed_count = len(self._line_widths)
|
|
144
|
+
if line_no < flushed_count:
|
|
145
|
+
return self._line_widths[line_no]
|
|
146
|
+
else:
|
|
147
|
+
# It's in the pending batch
|
|
148
|
+
pending_idx = line_no - flushed_count
|
|
149
|
+
return self._pending_widths[pending_idx]
|
|
150
|
+
|
|
151
|
+
def get_total_display_rows(self, width: int) -> int:
|
|
152
|
+
"""
|
|
153
|
+
Get total display rows for all lines at given terminal width.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
width: Terminal width
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Total number of display rows
|
|
160
|
+
"""
|
|
161
|
+
if width <= 0:
|
|
162
|
+
return 0 # No display possible with zero or negative width
|
|
163
|
+
if width > MAX_WIDTH:
|
|
164
|
+
width = MAX_WIDTH
|
|
165
|
+
|
|
166
|
+
total_rows = 0
|
|
167
|
+
|
|
168
|
+
# Add up complete summaries
|
|
169
|
+
complete_summaries = self._line_count // SUMMARY_INTERVAL
|
|
170
|
+
for i in range(complete_summaries):
|
|
171
|
+
summary_offset = i * MAX_WIDTH + (width - 1)
|
|
172
|
+
total_rows += self._summaries[summary_offset]
|
|
173
|
+
|
|
174
|
+
# Add remaining lines not in a summary
|
|
175
|
+
start_line = complete_summaries * SUMMARY_INTERVAL
|
|
176
|
+
for line_idx in range(start_line, self._line_count):
|
|
177
|
+
line_width = self.get_line_width(line_idx)
|
|
178
|
+
# Ceiling division is always >= 1 for positive operands
|
|
179
|
+
rows = (line_width + width - 1) // width if width > 0 and line_width > 0 else 1
|
|
180
|
+
total_rows += rows
|
|
181
|
+
|
|
182
|
+
return total_rows
|
|
183
|
+
|
|
184
|
+
def get_display_row_for_line(self, line_no: int, width: int) -> int:
|
|
185
|
+
"""
|
|
186
|
+
Get the display row number where a logical line starts.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
line_no: Logical line number
|
|
190
|
+
width: Terminal width
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Display row number where this line starts
|
|
194
|
+
"""
|
|
195
|
+
if line_no < 0 or line_no >= self._line_count:
|
|
196
|
+
raise IndexError(f"Line {line_no} out of range")
|
|
197
|
+
|
|
198
|
+
if width <= 0:
|
|
199
|
+
return 0 # No display possible
|
|
200
|
+
if width > MAX_WIDTH:
|
|
201
|
+
width = MAX_WIDTH
|
|
202
|
+
|
|
203
|
+
display_row = 0
|
|
204
|
+
|
|
205
|
+
# Add complete summaries before this line
|
|
206
|
+
summary_idx = line_no // SUMMARY_INTERVAL
|
|
207
|
+
for i in range(summary_idx):
|
|
208
|
+
summary_offset = i * MAX_WIDTH + (width - 1)
|
|
209
|
+
display_row += self._summaries[summary_offset]
|
|
210
|
+
|
|
211
|
+
# Add individual lines from last summary to target line
|
|
212
|
+
start_line = summary_idx * SUMMARY_INTERVAL
|
|
213
|
+
for line_idx in range(start_line, line_no):
|
|
214
|
+
line_width = self.get_line_width(line_idx)
|
|
215
|
+
rows = (line_width + width - 1) // width if width > 0 and line_width > 0 else 1
|
|
216
|
+
display_row += rows
|
|
217
|
+
|
|
218
|
+
return display_row
|
|
219
|
+
|
|
220
|
+
def get_line_for_display_row(self, display_row: int, width: int) -> Tuple[int, int]:
|
|
221
|
+
"""
|
|
222
|
+
Find the logical line containing the given display row.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
display_row: Display row to find
|
|
226
|
+
width: Terminal width
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (line_number, row_offset_within_line)
|
|
230
|
+
"""
|
|
231
|
+
if width <= 0:
|
|
232
|
+
raise IndexError(f"Display row {display_row} out of range") # No display possible
|
|
233
|
+
if width > MAX_WIDTH:
|
|
234
|
+
width = MAX_WIDTH
|
|
235
|
+
|
|
236
|
+
current_row = 0
|
|
237
|
+
|
|
238
|
+
# Binary search through summaries to find the right range
|
|
239
|
+
complete_summaries = self._line_count // SUMMARY_INTERVAL
|
|
240
|
+
summary_idx = 0
|
|
241
|
+
|
|
242
|
+
# Find which summary block contains our display row
|
|
243
|
+
for i in range(complete_summaries):
|
|
244
|
+
summary_offset = i * MAX_WIDTH + (width - 1)
|
|
245
|
+
summary_rows = self._summaries[summary_offset]
|
|
246
|
+
if current_row + summary_rows > display_row:
|
|
247
|
+
summary_idx = i
|
|
248
|
+
break
|
|
249
|
+
current_row += summary_rows
|
|
250
|
+
else:
|
|
251
|
+
# It's in the incomplete last block
|
|
252
|
+
summary_idx = complete_summaries
|
|
253
|
+
|
|
254
|
+
# Linear search within the summary block
|
|
255
|
+
start_line = summary_idx * SUMMARY_INTERVAL
|
|
256
|
+
end_line = min(start_line + SUMMARY_INTERVAL, self._line_count)
|
|
257
|
+
|
|
258
|
+
for line_idx in range(start_line, end_line):
|
|
259
|
+
line_width = self.get_line_width(line_idx)
|
|
260
|
+
rows = (line_width + width - 1) // width if width > 0 and line_width > 0 else 1
|
|
261
|
+
|
|
262
|
+
if current_row + rows > display_row:
|
|
263
|
+
# Found the line
|
|
264
|
+
offset_within_line = display_row - current_row
|
|
265
|
+
return (line_idx, offset_within_line)
|
|
266
|
+
|
|
267
|
+
current_row += rows
|
|
268
|
+
|
|
269
|
+
# Display row is beyond the end
|
|
270
|
+
raise IndexError(f"Display row {display_row} out of range")
|
|
271
|
+
|
|
272
|
+
def __len__(self) -> int:
|
|
273
|
+
"""Get total number of indexed lines."""
|
|
274
|
+
return self._line_count
|