mui 0.1.0 → 0.2.0

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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +158 -0
  3. data/CHANGELOG.md +349 -0
  4. data/exe/mui +1 -2
  5. data/lib/mui/autocmd.rb +66 -0
  6. data/lib/mui/buffer.rb +275 -0
  7. data/lib/mui/buffer_word_cache.rb +131 -0
  8. data/lib/mui/buffer_word_completer.rb +77 -0
  9. data/lib/mui/color_manager.rb +136 -0
  10. data/lib/mui/color_scheme.rb +63 -0
  11. data/lib/mui/command_completer.rb +21 -0
  12. data/lib/mui/command_context.rb +90 -0
  13. data/lib/mui/command_line.rb +137 -0
  14. data/lib/mui/command_registry.rb +25 -0
  15. data/lib/mui/completion_renderer.rb +84 -0
  16. data/lib/mui/completion_state.rb +58 -0
  17. data/lib/mui/config.rb +56 -0
  18. data/lib/mui/editor.rb +319 -0
  19. data/lib/mui/error.rb +29 -0
  20. data/lib/mui/file_completer.rb +51 -0
  21. data/lib/mui/floating_window.rb +161 -0
  22. data/lib/mui/handler_result.rb +101 -0
  23. data/lib/mui/highlight.rb +22 -0
  24. data/lib/mui/highlighters/base.rb +23 -0
  25. data/lib/mui/highlighters/search_highlighter.rb +26 -0
  26. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  27. data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
  28. data/lib/mui/input.rb +17 -0
  29. data/lib/mui/insert_completion_renderer.rb +92 -0
  30. data/lib/mui/insert_completion_state.rb +77 -0
  31. data/lib/mui/job.rb +81 -0
  32. data/lib/mui/job_manager.rb +113 -0
  33. data/lib/mui/key_code.rb +30 -0
  34. data/lib/mui/key_handler/base.rb +100 -0
  35. data/lib/mui/key_handler/command_mode.rb +443 -0
  36. data/lib/mui/key_handler/insert_mode.rb +354 -0
  37. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  38. data/lib/mui/key_handler/normal_mode.rb +579 -0
  39. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  40. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  41. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  42. data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
  43. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  44. data/lib/mui/key_handler/search_mode.rb +188 -0
  45. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  46. data/lib/mui/key_handler/visual_mode.rb +397 -0
  47. data/lib/mui/key_handler/window_command.rb +112 -0
  48. data/lib/mui/key_handler.rb +16 -0
  49. data/lib/mui/layout/calculator.rb +15 -0
  50. data/lib/mui/layout/leaf_node.rb +33 -0
  51. data/lib/mui/layout/node.rb +29 -0
  52. data/lib/mui/layout/split_node.rb +132 -0
  53. data/lib/mui/line_renderer.rb +122 -0
  54. data/lib/mui/mode.rb +13 -0
  55. data/lib/mui/mode_manager.rb +185 -0
  56. data/lib/mui/motion.rb +139 -0
  57. data/lib/mui/plugin.rb +35 -0
  58. data/lib/mui/plugin_manager.rb +106 -0
  59. data/lib/mui/register.rb +110 -0
  60. data/lib/mui/screen.rb +85 -0
  61. data/lib/mui/search_completer.rb +50 -0
  62. data/lib/mui/search_input.rb +40 -0
  63. data/lib/mui/search_state.rb +88 -0
  64. data/lib/mui/selection.rb +55 -0
  65. data/lib/mui/status_line_renderer.rb +40 -0
  66. data/lib/mui/syntax/language_detector.rb +74 -0
  67. data/lib/mui/syntax/lexer_base.rb +106 -0
  68. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  69. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  70. data/lib/mui/syntax/token.rb +42 -0
  71. data/lib/mui/syntax/token_cache.rb +91 -0
  72. data/lib/mui/tab_bar_renderer.rb +87 -0
  73. data/lib/mui/tab_manager.rb +96 -0
  74. data/lib/mui/tab_page.rb +35 -0
  75. data/lib/mui/terminal_adapter/base.rb +92 -0
  76. data/lib/mui/terminal_adapter/curses.rb +162 -0
  77. data/lib/mui/terminal_adapter.rb +4 -0
  78. data/lib/mui/themes/default.rb +315 -0
  79. data/lib/mui/undo_manager.rb +83 -0
  80. data/lib/mui/undoable_action.rb +175 -0
  81. data/lib/mui/unicode_width.rb +100 -0
  82. data/lib/mui/version.rb +1 -1
  83. data/lib/mui/window.rb +158 -0
  84. data/lib/mui/window_manager.rb +249 -0
  85. data/lib/mui.rb +156 -2
  86. metadata +98 -3
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ # Base class for language-specific lexers
6
+ # Subclasses should override token_patterns and optionally handle_multiline_state
7
+ class LexerBase
8
+ # Tokenize a single line of text
9
+ # TODO: Refactor to reduce complexity (Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity)
10
+ def tokenize(line, state = nil)
11
+ tokens = []
12
+ pos = 0
13
+ current_state = state
14
+
15
+ while pos < line.length
16
+ # Handle multiline state first (e.g., inside block comment)
17
+ if current_state
18
+ token, new_state, new_pos = handle_multiline_state(line, pos, current_state)
19
+ if token
20
+ tokens << token
21
+ pos = new_pos
22
+ current_state = new_state
23
+ next
24
+ elsif new_state.nil?
25
+ # State ended, continue normal tokenization
26
+ current_state = nil
27
+ pos = new_pos
28
+ next
29
+ end
30
+ end
31
+
32
+ # Check for multiline state start
33
+ new_state, token, new_pos = check_multiline_start(line, pos)
34
+ if new_state
35
+ tokens << token if token
36
+ pos = new_pos
37
+ current_state = new_state
38
+ next
39
+ end
40
+
41
+ # Normal token matching
42
+ token = match_token(line, pos)
43
+ if token
44
+ tokens << token
45
+ pos = token.end_col + 1
46
+ else
47
+ # Skip unrecognized character
48
+ pos += 1
49
+ end
50
+ end
51
+
52
+ [tokens, current_state]
53
+ end
54
+
55
+ # Check if a state continues to the next line
56
+ def continuing_state?(state)
57
+ !state.nil?
58
+ end
59
+
60
+ protected
61
+
62
+ # Override in subclass to define token patterns
63
+ def token_patterns
64
+ []
65
+ end
66
+
67
+ # Override in subclass to handle multiline constructs
68
+ def handle_multiline_state(_line, pos, _state)
69
+ [nil, nil, pos]
70
+ end
71
+
72
+ # Override in subclass to check for multiline construct starts
73
+ def check_multiline_start(_line, pos)
74
+ [nil, nil, pos]
75
+ end
76
+
77
+ private
78
+
79
+ # Get compiled patterns (cached)
80
+ # Uses \G anchor for efficient matching at specific position
81
+ def compiled_patterns
82
+ @compiled_patterns ||= token_patterns.map do |type, pattern|
83
+ [type, /\G#{pattern}/]
84
+ end
85
+ end
86
+
87
+ def match_token(line, pos)
88
+ return nil if pos >= line.length
89
+
90
+ compiled_patterns.each do |type, pattern|
91
+ match = pattern.match(line, pos)
92
+ next unless match
93
+
94
+ text = match[0]
95
+ return Token.new(
96
+ type:,
97
+ start_col: pos,
98
+ end_col: pos + text.length - 1,
99
+ text:
100
+ )
101
+ end
102
+ nil
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ module Lexers
6
+ # Lexer for C source code
7
+ class CLexer < LexerBase
8
+ # Pre-compiled patterns with \G anchor for position-specific matching
9
+ # These are compiled once at class load time
10
+ COMPILED_PATTERNS = [
11
+ # Single line comment
12
+ [:comment, %r{\G//.*}],
13
+ # Single-line block comment /* ... */ on one line
14
+ [:comment, %r{\G/\*.*?\*/}],
15
+ # Double quoted string (with escape handling)
16
+ [:string, /\G"(?:[^"\\]|\\.)*"/],
17
+ # Character literal
18
+ [:char, /\G'(?:[^'\\]|\\.)*'/],
19
+ # Preprocessor directives
20
+ [:preprocessor, /\G^\s*#\s*(?:include|define|undef|ifdef|ifndef|if|else|elif|endif|error|pragma|line)\b.*/],
21
+ # Float numbers (must be before integer)
22
+ [:number, /\G\b\d+\.\d+(?:e[+-]?\d+)?[fFlL]?\b/i],
23
+ # Hexadecimal
24
+ [:number, /\G\b0x[0-9a-fA-F]+[uUlL]*\b/],
25
+ # Octal
26
+ [:number, /\G\b0[0-7]+[uUlL]*\b/],
27
+ # Integer
28
+ [:number, /\G\b\d+[uUlL]*\b/],
29
+ # Type keywords (int, char, void, etc.)
30
+ [:type, /\G\b(?:char|double|float|int|long|short|signed|unsigned|void|_Bool|_Complex|_Imaginary)\b/],
31
+ # Other keywords (if, for, return, const, static, etc.)
32
+ [:keyword, /\G\b(?:auto|break|case|const|continue|default|do|else|enum|extern|for|goto|if|register|return|sizeof|static|struct|switch|typedef|union|volatile|while|inline|restrict|_Alignas|_Alignof|_Atomic|_Generic|_Noreturn|_Static_assert|_Thread_local)\b/],
33
+ # Identifiers
34
+ [:identifier, /\G\b[a-zA-Z_][a-zA-Z0-9_]*\b/],
35
+ # Operators
36
+ [:operator, %r{\G(?:[+\-*/%&|^~<>=!]+|->|<<|>>|\+\+|--)}]
37
+ ].freeze
38
+
39
+ # Multiline comment patterns (pre-compiled)
40
+ BLOCK_COMMENT_END = %r{\*/}
41
+ BLOCK_COMMENT_START = %r{/\*}
42
+ BLOCK_COMMENT_START_ANCHOR = %r{\A/\*}
43
+
44
+ protected
45
+
46
+ # Use pre-compiled class-level patterns
47
+ def compiled_patterns
48
+ COMPILED_PATTERNS
49
+ end
50
+
51
+ # Handle /* ... */ block comments that span multiple lines
52
+ def handle_multiline_state(line, pos, state)
53
+ return [nil, nil, pos] unless state == :block_comment
54
+
55
+ # Look for */
56
+ end_match = line[pos..].match(BLOCK_COMMENT_END)
57
+ if end_match
58
+ end_pos = pos + end_match.begin(0) + 1
59
+ text = line[pos..end_pos]
60
+ token = Token.new(
61
+ type: :comment,
62
+ start_col: pos,
63
+ end_col: end_pos,
64
+ text:
65
+ )
66
+ [token, nil, end_pos + 1]
67
+ else
68
+ # Entire line is part of block comment
69
+ text = line[pos..]
70
+ unless text.empty?
71
+ token = Token.new(
72
+ type: :comment,
73
+ start_col: pos,
74
+ end_col: line.length - 1,
75
+ text:
76
+ )
77
+ end
78
+ [token, :block_comment, line.length]
79
+ end
80
+ end
81
+
82
+ # Check for /* block comment start (that doesn't end on the same line)
83
+ def check_multiline_start(line, pos)
84
+ rest = line[pos..]
85
+
86
+ # Check for /* that doesn't have a matching */ on this line
87
+ start_match = rest.match(BLOCK_COMMENT_START)
88
+ return [nil, nil, pos] unless start_match
89
+
90
+ start_pos = pos + start_match.begin(0)
91
+ after_start = line[(start_pos + 2)..]
92
+
93
+ # Check if there's a closing */ on the same line after this /*
94
+ if after_start&.include?("*/")
95
+ # There's a closing on this line, let normal token matching handle it
96
+ [nil, nil, pos]
97
+ else
98
+ # No closing on this line, enter block comment state
99
+ text = line[start_pos..]
100
+ token = Token.new(
101
+ type: :comment,
102
+ start_col: start_pos,
103
+ end_col: line.length - 1,
104
+ text:
105
+ )
106
+ [:block_comment, token, line.length]
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def match_token(line, pos)
113
+ # First check for start of multiline comment
114
+ if line[pos..].match?(BLOCK_COMMENT_START_ANCHOR)
115
+ rest = line[(pos + 2)..]
116
+ unless rest&.include?("*/")
117
+ # This will be handled by check_multiline_start
118
+ return nil
119
+ end
120
+ end
121
+
122
+ super
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ module Lexers
6
+ # Lexer for Ruby source code
7
+ class RubyLexer < LexerBase
8
+ KEYWORDS = %w[
9
+ BEGIN END __ENCODING__ __END__ __FILE__ __LINE__
10
+ alias and begin break case class def defined? do
11
+ else elsif end ensure false for if in module next
12
+ nil not or redo rescue retry return self super then
13
+ true undef unless until when while yield
14
+ require require_relative attr_reader attr_writer attr_accessor
15
+ private protected public include extend prepend
16
+ raise fail catch throw
17
+ ].freeze
18
+
19
+ # Pre-compiled patterns with \G anchor for position-specific matching
20
+ # These are compiled once at class load time
21
+ COMPILED_PATTERNS = [
22
+ # Single line comment
23
+ [:comment, /\G#.*/],
24
+ # Double quoted string (with escape handling)
25
+ [:string, /\G"(?:[^"\\]|\\.)*"/],
26
+ # Single quoted string (with escape handling)
27
+ [:string, /\G'(?:[^'\\]|\\.)*'/],
28
+ # Symbols
29
+ [:symbol, /\G:[a-zA-Z_][a-zA-Z0-9_]*[?!]?/],
30
+ # Float numbers (must be before integer)
31
+ [:number, /\G\b\d+\.\d+(?:e[+-]?\d+)?\b/i],
32
+ # Hexadecimal
33
+ [:number, /\G\b0x[0-9a-f]+\b/i],
34
+ # Octal
35
+ [:number, /\G\b0o?[0-7]+\b/],
36
+ # Binary
37
+ [:number, /\G\b0b[01]+\b/i],
38
+ # Integer
39
+ [:number, /\G\b\d+\b/],
40
+ # Constants (capitalized identifiers)
41
+ [:constant, /\G\b[A-Z][a-zA-Z0-9_]*\b/],
42
+ # Keywords
43
+ [:keyword, /\G\b(?:BEGIN|END|__ENCODING__|__END__|__FILE__|__LINE__|alias|and|begin|break|case|class|def|defined\?|do|else|elsif|end|ensure|false|for|if|in|module|next|nil|not|or|redo|rescue|retry|return|self|super|then|true|undef|unless|until|when|while|yield|require|require_relative|attr_reader|attr_writer|attr_accessor|private|protected|public|include|extend|prepend|raise|fail|catch|throw)\b/],
44
+ # Instance variables (@foo, @@foo)
45
+ [:instance_variable, /\G@{1,2}[a-zA-Z_][a-zA-Z0-9_]*/],
46
+ # Global variables
47
+ [:global_variable, /\G\$[a-zA-Z_][a-zA-Z0-9_]*/],
48
+ # Method calls (.to_i, .each, .map!, .empty?, etc.)
49
+ [:method_call, /\G\.[a-z_][a-zA-Z0-9_]*[?!]?/],
50
+ # Identifiers (including method names with ? or !)
51
+ [:identifier, /\G\b[a-z_][a-zA-Z0-9_]*[?!]?/],
52
+ # Operators
53
+ [:operator, %r{\G(?:[+\-*/%&|^~<>=!]+|<<|>>|\*\*)}]
54
+ ].freeze
55
+
56
+ # Multiline patterns (also pre-compiled)
57
+ BLOCK_COMMENT_END = /\A=end\b/
58
+ BLOCK_COMMENT_START = /\A=begin\b/
59
+
60
+ protected
61
+
62
+ # Use pre-compiled class-level patterns
63
+ def compiled_patterns
64
+ COMPILED_PATTERNS
65
+ end
66
+
67
+ # Handle =begin...=end block comments
68
+ def handle_multiline_state(line, pos, state)
69
+ return [nil, nil, pos] unless state == :block_comment
70
+
71
+ # Check for =end
72
+ text = line[pos..]
73
+ if BLOCK_COMMENT_END.match?(text)
74
+ token = Token.new(
75
+ type: :comment,
76
+ start_col: pos,
77
+ end_col: line.length - 1,
78
+ text:
79
+ )
80
+ [token, nil, line.length]
81
+ else
82
+ # Entire line is part of block comment
83
+ unless text.empty?
84
+ token = Token.new(
85
+ type: :comment,
86
+ start_col: pos,
87
+ end_col: line.length - 1,
88
+ text:
89
+ )
90
+ end
91
+ [token, :block_comment, line.length]
92
+ end
93
+ end
94
+
95
+ # Check for =begin block comment start
96
+ def check_multiline_start(line, pos)
97
+ return [nil, nil, pos] unless pos.zero?
98
+
99
+ if BLOCK_COMMENT_START.match?(line)
100
+ token = Token.new(
101
+ type: :comment,
102
+ start_col: 0,
103
+ end_col: line.length - 1,
104
+ text: line
105
+ )
106
+ [:block_comment, token, line.length]
107
+ else
108
+ [nil, nil, pos]
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ # Represents a token identified by a lexer
6
+ # Tokens are immutable value objects
7
+ class Token
8
+ attr_reader :type, :start_col, :end_col, :text
9
+
10
+ # Token types:
11
+ # :keyword - language keywords (def, class, if, etc.)
12
+ # :string - string literals ("...", '...')
13
+ # :comment - comments (#..., //..., /* ... */)
14
+ # :number - numeric literals (123, 1.5, 0xFF)
15
+ # :symbol - Ruby symbols (:symbol)
16
+ # :constant - constants (ClassName, CONST)
17
+ # :operator - operators (+, -, =, etc.)
18
+ # :identifier - variable names, method names
19
+ # :preprocessor - C preprocessor directives (#include, #define)
20
+ # :char - character literals ('a')
21
+ def initialize(type:, start_col:, end_col:, text:)
22
+ @type = type
23
+ @start_col = start_col
24
+ @end_col = end_col
25
+ @text = text
26
+ end
27
+
28
+ def ==(other)
29
+ return false unless other.is_a?(Token)
30
+
31
+ type == other.type &&
32
+ start_col == other.start_col &&
33
+ end_col == other.end_col &&
34
+ text == other.text
35
+ end
36
+
37
+ def length
38
+ text.length
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ # Caches tokenization results on a per-line basis
6
+ # Handles multiline state propagation across lines
7
+ class TokenCache
8
+ # Number of lines to prefetch ahead/behind visible area
9
+ PREFETCH_LINES = 50
10
+
11
+ def initialize(lexer)
12
+ @lexer = lexer
13
+ @cache = {} # row => { tokens:, line_hash:, state_after: }
14
+ end
15
+
16
+ # Get tokens for a specific line, using cache when available
17
+ # Only tokenizes the requested line - does NOT pre-compute previous lines
18
+ def tokens_for(row, line, _buffer_lines)
19
+ line_hash = line.hash
20
+
21
+ # Check cache validity (single hash lookup)
22
+ cached = @cache[row]
23
+ return cached[:tokens] if cached && cached[:line_hash] == line_hash
24
+
25
+ # Get state from previous line's cache (if available)
26
+ # If not available, assume nil (no multiline state)
27
+ # This trades accuracy for performance - multiline constructs
28
+ # may not highlight correctly until user scrolls through the file
29
+ prev_cached = @cache[row - 1]
30
+ state_before = prev_cached&.dig(:state_after)
31
+
32
+ # Tokenize this line
33
+ tokens, state_after = @lexer.tokenize(line, state_before)
34
+
35
+ # Store in cache
36
+ @cache[row] = {
37
+ tokens:,
38
+ line_hash:,
39
+ state_after:
40
+ }
41
+
42
+ tokens
43
+ end
44
+
45
+ # Prefetch tokens for lines around the visible area
46
+ def prefetch(visible_start, visible_end, buffer_lines)
47
+ return if buffer_lines.empty?
48
+
49
+ # Calculate prefetch range
50
+ prefetch_start = [visible_start - PREFETCH_LINES, 0].max
51
+ prefetch_end = [visible_end + PREFETCH_LINES, buffer_lines.length - 1].min
52
+
53
+ # Tokenize lines that aren't cached yet
54
+ (prefetch_start..prefetch_end).each do |row|
55
+ line = buffer_lines[row]
56
+ next if line.nil?
57
+
58
+ line_hash = line.hash
59
+ cached = @cache[row]
60
+ next if cached && cached[:line_hash] == line_hash
61
+
62
+ prev_cached = @cache[row - 1]
63
+ state_before = prev_cached&.dig(:state_after)
64
+ tokens, state_after = @lexer.tokenize(line, state_before)
65
+
66
+ @cache[row] = {
67
+ tokens:,
68
+ line_hash:,
69
+ state_after:
70
+ }
71
+ end
72
+ end
73
+
74
+ # Invalidate cache from a specific row onwards
75
+ # This is called when a line is modified
76
+ def invalidate(from_row)
77
+ @cache.delete_if { |row, _| row >= from_row }
78
+ end
79
+
80
+ # Clear the entire cache
81
+ def clear
82
+ @cache.clear
83
+ end
84
+
85
+ # Check if a row has cached data
86
+ def cached?(row)
87
+ @cache.key?(row)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Renders the tab bar at the top of the screen
5
+ class TabBarRenderer
6
+ TAB_BAR_HEIGHT = 1
7
+ SEPARATOR_HEIGHT = 1
8
+ TAB_SEPARATOR = " | "
9
+ SEPARATOR_CHAR = "─"
10
+
11
+ def initialize(tab_manager, color_scheme: nil)
12
+ @tab_manager = tab_manager
13
+ @color_scheme = color_scheme
14
+ end
15
+
16
+ def render(screen, row = 0)
17
+ return unless should_render?
18
+
19
+ render_tabs(screen, row)
20
+ render_separator_line(screen, row + TAB_BAR_HEIGHT)
21
+ end
22
+
23
+ def height
24
+ should_render? ? TAB_BAR_HEIGHT + SEPARATOR_HEIGHT : 0
25
+ end
26
+
27
+ private
28
+
29
+ def should_render?
30
+ @tab_manager.tab_count > 1
31
+ end
32
+
33
+ def render_tabs(screen, row)
34
+ col = 0
35
+
36
+ @tab_manager.tabs.each_with_index do |tab, i|
37
+ # Add separator between tabs
38
+ if i.positive?
39
+ screen.put_with_style(row, col, TAB_SEPARATOR, tab_bar_style)
40
+ col += TAB_SEPARATOR.length
41
+ end
42
+
43
+ # Render tab with appropriate style
44
+ tab_text = build_tab_text(tab, i)
45
+ style = i == @tab_manager.current_index ? tab_bar_active_style : tab_bar_style
46
+ screen.put_with_style(row, col, tab_text, style)
47
+ col += tab_text.length
48
+ end
49
+
50
+ # Fill remaining space with tab bar style
51
+ remaining = screen.width - col
52
+ screen.put_with_style(row, col, " " * remaining, tab_bar_style) if remaining.positive?
53
+ end
54
+
55
+ def render_separator_line(screen, row)
56
+ separator_line = SEPARATOR_CHAR * screen.width
57
+ screen.put_with_style(row, 0, separator_line, separator_style)
58
+ end
59
+
60
+ def build_tab_text(tab, index)
61
+ marker = index == @tab_manager.current_index ? "*" : " "
62
+ "#{marker}#{index + 1}:#{truncate_name(tab.display_name, 15)}"
63
+ end
64
+
65
+ def truncate_name(name, max_length)
66
+ return name if name.length <= max_length
67
+
68
+ "#{name[0, max_length - 1]}~"
69
+ end
70
+
71
+ def tab_bar_style
72
+ @color_scheme&.[](:tab_bar) || @color_scheme&.[](:status_line) || default_style
73
+ end
74
+
75
+ def tab_bar_active_style
76
+ @color_scheme&.[](:tab_bar_active) || @color_scheme&.[](:status_line_mode) || default_style
77
+ end
78
+
79
+ def separator_style
80
+ @color_scheme&.[](:separator) || @color_scheme&.[](:status_line) || default_style
81
+ end
82
+
83
+ def default_style
84
+ { fg: :white, bg: :blue, bold: false, underline: false }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages multiple tab pages
5
+ # Each tab page has its own window layout
6
+ class TabManager
7
+ attr_reader :tabs, :current_index
8
+
9
+ def initialize(screen, color_scheme: nil)
10
+ @screen = screen
11
+ @color_scheme = color_scheme
12
+ @tabs = []
13
+ @current_index = 0
14
+ end
15
+
16
+ def current_tab
17
+ @tabs[@current_index]
18
+ end
19
+
20
+ def add(tab = nil)
21
+ tab ||= TabPage.new(@screen, color_scheme: @color_scheme)
22
+ @tabs << tab
23
+ @current_index = @tabs.size - 1
24
+ tab
25
+ end
26
+
27
+ # rubocop:disable Naming/PredicateMethod
28
+ def close_current
29
+ return false if single_tab?
30
+
31
+ @tabs.delete_at(@current_index)
32
+ @current_index = [@current_index, @tabs.size - 1].min
33
+ true
34
+ end
35
+ # rubocop:enable Naming/PredicateMethod
36
+
37
+ def next_tab
38
+ return if @tabs.empty?
39
+
40
+ @current_index = (@current_index + 1) % @tabs.size
41
+ end
42
+
43
+ def prev_tab
44
+ return if @tabs.empty?
45
+
46
+ @current_index = (@current_index - 1) % @tabs.size
47
+ end
48
+
49
+ def first_tab
50
+ return if @tabs.empty?
51
+
52
+ @current_index = 0
53
+ end
54
+
55
+ def last_tab
56
+ return if @tabs.empty?
57
+
58
+ @current_index = @tabs.size - 1
59
+ end
60
+
61
+ # rubocop:disable Naming/PredicateMethod
62
+ def go_to(index)
63
+ return false if index.negative? || index >= @tabs.size
64
+
65
+ @current_index = index
66
+ true
67
+ end
68
+
69
+ def move_tab(position)
70
+ return false if @tabs.size <= 1
71
+
72
+ tab = @tabs.delete_at(@current_index)
73
+ new_position = position.clamp(0, @tabs.size)
74
+ @tabs.insert(new_position, tab)
75
+ @current_index = new_position
76
+ true
77
+ end
78
+ # rubocop:enable Naming/PredicateMethod
79
+
80
+ def tab_count
81
+ @tabs.size
82
+ end
83
+
84
+ def single_tab?
85
+ @tabs.size <= 1
86
+ end
87
+
88
+ def window_manager
89
+ current_tab&.window_manager
90
+ end
91
+
92
+ def active_window
93
+ current_tab&.active_window
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Represents a single tab page containing a window manager
5
+ # Each tab page has its own independent window layout
6
+ class TabPage
7
+ attr_reader :window_manager
8
+ attr_accessor :name
9
+
10
+ def initialize(screen, color_scheme: nil, name: nil)
11
+ @window_manager = WindowManager.new(screen, color_scheme:)
12
+ @name = name
13
+ end
14
+
15
+ def active_window
16
+ @window_manager.active_window
17
+ end
18
+
19
+ def layout_root
20
+ @window_manager.layout_root
21
+ end
22
+
23
+ def windows
24
+ @window_manager.windows
25
+ end
26
+
27
+ def window_count
28
+ @window_manager.window_count
29
+ end
30
+
31
+ def display_name
32
+ @name || active_window&.buffer&.name || "[No Name]"
33
+ end
34
+ end
35
+ end