grmenu 3.0.0 → 4.0.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.
data/GRmenu.rb CHANGED
@@ -30,9 +30,37 @@ class GRmenu
30
30
  end
31
31
 
32
32
  COLORS = load_json_data('colors.json').freeze
33
- BORDERS = load_json_data('borders.json').transform_keys(&:to_i).transform_values { |v| v.transform_keys(&:to_sym) }.freeze
33
+ BORDERS = load_json_data('borders.json').transform_keys(&:to_i).transform_values { |v| v.is_a?(Hash) ? v.transform_keys(&:to_sym) : { h: v.to_s, v: v.to_s, tl: v.to_s, tr: v.to_s, bl: v.to_s, br: v.to_s } }.freeze
34
34
  FONTS = load_json_data('fonts.json').transform_keys(&:to_i).freeze
35
35
 
36
+ BASE_RGB = COLORS.each_with_object({}) do |(name, val), h|
37
+ next if name == "reset"
38
+ code = val.is_a?(Hash) ? (val["2"] || val[2] || val["1"] || val[1]) : val.to_s
39
+ if code =~ /38;2;(\d+);(\d+);(\d+)/
40
+ h[name] = [$1.to_i, $2.to_i, $3.to_i]
41
+ elsif code == "90m" || code == "30m"
42
+ h[name] = [100, 100, 100]
43
+ elsif code == "91m" || code == "31m"
44
+ h[name] = [255, 60, 60]
45
+ elsif code == "92m" || code == "32m"
46
+ h[name] = [60, 255, 60]
47
+ elsif code == "93m" || code == "33m"
48
+ h[name] = [255, 255, 60]
49
+ elsif code == "94m" || code == "34m"
50
+ h[name] = [60, 120, 255]
51
+ elsif code == "95m" || code == "35m"
52
+ h[name] = [255, 60, 255]
53
+ elsif code == "96m" || code == "36m"
54
+ h[name] = [60, 255, 255]
55
+ elsif code == "97m" || code == "37m"
56
+ h[name] = [250, 250, 250]
57
+ elsif code =~ /38;5;(\d+)/
58
+ h[name] = [150, 150, 150]
59
+ else
60
+ h[name] = [220, 220, 220]
61
+ end
62
+ end.freeze
63
+
36
64
  FONT_1 = FONTS[1] || {}
37
65
  FONT_2 = FONTS[2] || {}
38
66
  FONT_3 = FONTS[3] || {}
@@ -53,8 +81,26 @@ class GRmenu
53
81
  end
54
82
 
55
83
  def self.ansi_color(color_name, level = 1)
56
- name = color_name.to_s.downcase
84
+ name = color_name.to_s.downcase.strip
85
+ if name.include?(":")
86
+ parts = name.split(":")
87
+ name = parts[0].strip
88
+ level = parts[1].to_i if parts[1] && !parts[1].empty?
89
+ end
57
90
  return rgb_color(0.0) if name == "rgb" || name == "rainbow" || name == "chroma"
91
+ if name =~ /\A#?([0-9a-f]{6})\z/i
92
+ hex = $1
93
+ r = hex[0..1].to_i(16)
94
+ g = hex[2..3].to_i(16)
95
+ b = hex[4..5].to_i(16)
96
+ return "\e[38;2;#{r};#{g};#{b}m"
97
+ elsif name =~ /\A#?([0-9a-f]{3})\z/i
98
+ hex = $1
99
+ r = (hex[0] * 2).to_i(16)
100
+ g = (hex[1] * 2).to_i(16)
101
+ b = (hex[2] * 2).to_i(16)
102
+ return "\e[38;2;#{r};#{g};#{b}m"
103
+ end
58
104
  lvl_str = level.to_s
59
105
  code_raw = COLORS.dig(name, lvl_str) || COLORS.dig(name, level.to_i) || COLORS[name]
60
106
  return "\e[#{code_raw}" if code_raw
@@ -83,7 +129,20 @@ class GRmenu
83
129
  cyan: { 1 => "\e[36m", 2 => "\e[96m" },
84
130
  aqua: { 1 => "\e[38;5;45m", 2 => "\e[38;5;51m" },
85
131
  orange: { 1 => "\e[38;5;208m", 2 => "\e[38;5;214m" },
86
- white: { 1 => "\e[37m", 2 => "\e[97m" }
132
+ white: { 1 => "\e[37m", 2 => "\e[97m" },
133
+ neon_red: { 1 => "\e[38;2;230;10;50m", 2 => "\e[38;2;255;7;58;1m" },
134
+ neon_green: { 1 => "\e[38;2;40;220;20m", 2 => "\e[38;2;57;255;20;1m" },
135
+ neon_cyan: { 1 => "\e[38;2;0;210;255m", 2 => "\e[38;2;0;245;255;1m" },
136
+ neon_blue: { 1 => "\e[38;2;0;150;255m", 2 => "\e[38;2;0;191;255;1m" },
137
+ neon_pink: { 1 => "\e[38;2;230;10;210m", 2 => "\e[38;2;255;16;240;1m" },
138
+ neon_yellow: { 1 => "\e[38;2;230;200;0m", 2 => "\e[38;2;255;235;0;1m" },
139
+ neon_orange: { 1 => "\e[38;2;240;80;0m", 2 => "\e[38;2;255;105;0;1m" },
140
+ neon_purple: { 1 => "\e[38;2;160;0;220m", 2 => "\e[38;2;191;0;255;1m" },
141
+ neon_magenta: { 1 => "\e[38;2;220;0;220m", 2 => "\e[38;2;255;0;255;1m" },
142
+ neon_aqua: { 1 => "\e[38;2;0;220;210m", 2 => "\e[38;2;0;255;230;1m" },
143
+ neon_lime: { 1 => "\e[38;2;120;240;0m", 2 => "\e[38;2;170;255;0;1m" },
144
+ neon_white: { 1 => "\e[38;2;220;230;255m", 2 => "\e[38;2;255;255;255;1m" },
145
+ neon: { 1 => "\e[38;2;230;10;50m", 2 => "\e[38;2;255;7;58;1m" }
87
146
  }.freeze
88
147
 
89
148
  module_function
@@ -93,7 +152,7 @@ class GRmenu
93
152
  if c_str == "rgb" || c_str == "rainbow" || c_str == "chroma"
94
153
  return rgb(text)
95
154
  end
96
- code = CODES.dig(color_name.to_sym, level) || "\e[37m"
155
+ code = CODES.dig(color_name.to_sym, level) || GRmenu.ansi_color(color_name, level) || "\e[37m"
97
156
  "#{code}#{text}#{RESET}"
98
157
  end
99
158
 
@@ -174,6 +233,19 @@ class GRmenu
174
233
  def bright_gray(s); paint(s, :gray, 2); end
175
234
  def grey(s); gray(s); end
176
235
 
236
+ def neon_red(s); paint(s, :neon_red, 2); end
237
+ def neon_green(s); paint(s, :neon_green, 2); end
238
+ def neon_cyan(s); paint(s, :neon_cyan, 2); end
239
+ def neon_blue(s); paint(s, :neon_blue, 2); end
240
+ def neon_pink(s); paint(s, :neon_pink, 2); end
241
+ def neon_yellow(s); paint(s, :neon_yellow, 2); end
242
+ def neon_orange(s); paint(s, :neon_orange, 2); end
243
+ def neon_purple(s); paint(s, :neon_purple, 2); end
244
+ def neon_magenta(s); paint(s, :neon_magenta, 2); end
245
+ def neon_aqua(s); paint(s, :neon_aqua, 2); end
246
+ def neon_lime(s); paint(s, :neon_lime, 2); end
247
+ def neon_white(s); paint(s, :neon_white, 2); end
248
+
177
249
  def r(s); bright_red(s); end
178
250
  def dr(s); dark_red(s); end
179
251
  def g(s); bright_green(s); end
@@ -183,6 +255,26 @@ class GRmenu
183
255
  def cy(s); bright_cyan(s); end
184
256
  def mg(s); bright_magenta(s); end
185
257
  def bl(s); bright_blue(s); end
258
+
259
+ def hex(code, text)
260
+ c = GRmenu.ansi_color(code.to_s)
261
+ "#{c}#{text}#{RESET}"
262
+ end
263
+
264
+ def respond_to_missing?(method_name, include_private = false)
265
+ GRmenu::COLORS.key?(method_name.to_s) || super
266
+ end
267
+
268
+ def method_missing(method_name, *args, &block)
269
+ m_str = method_name.to_s
270
+ if GRmenu::COLORS.key?(m_str)
271
+ text = args[0].to_s
272
+ lvl = args[1] || 2
273
+ paint(text, m_str, lvl)
274
+ else
275
+ super
276
+ end
277
+ end
186
278
  end
187
279
  C = Color
188
280
 
@@ -596,6 +688,9 @@ class GRmenu
596
688
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", tick)
597
689
  if @title && !@title.empty?
598
690
  t_str = @title.to_s
691
+ if GRmenu.display_width(t_str) > inner_w
692
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
693
+ end
599
694
  pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
600
695
  l_p = " " * (pad_t / 2)
601
696
  r_p = " " * (pad_t - (pad_t / 2))
@@ -612,6 +707,9 @@ class GRmenu
612
707
  lines << "#{Color.rgb(v_l, tick)} #{bar_line} #{Color.rgb(v_r, tick)}"
613
708
  if @status && !@status.empty?
614
709
  st_str = @status.to_s
710
+ if GRmenu.display_width(st_str) > inner_w
711
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
712
+ end
615
713
  pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
616
714
  st_line = st_str + (" " * pad_st)
617
715
  lines << "#{Color.rgb(v_l, tick)} #{Color.gray(st_line)} #{Color.rgb(v_r, tick)}"
@@ -628,6 +726,9 @@ class GRmenu
628
726
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
629
727
  if @title && !@title.empty?
630
728
  t_str = @title.to_s
729
+ if GRmenu.display_width(t_str) > inner_w
730
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
731
+ end
631
732
  pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
632
733
  l_p = " " * (pad_t / 2)
633
734
  r_p = " " * (pad_t - (pad_t / 2))
@@ -637,6 +738,9 @@ class GRmenu
637
738
  lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
638
739
  if @status && !@status.empty?
639
740
  st_str = @status.to_s
741
+ if GRmenu.display_width(st_str) > inner_w
742
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
743
+ end
640
744
  pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
641
745
  st_line = st_str + (" " * pad_st)
642
746
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(st_line)} #{color_code}#{v_r}#{reset_code}"
@@ -748,20 +852,24 @@ class GRmenu
748
852
  right_p = " " * (pad_total - (pad_total / 2))
749
853
  btn_formatted_line = "#{left_p}#{btn_yes} #{btn_no}#{right_p}"
750
854
 
751
- pad_q = [inner_w - display_width(question), 0].max
855
+ q_clean = question.to_s
856
+ if display_width(q_clean) > inner_w
857
+ q_clean = q_clean[0...[inner_w - 3, 1].max] + "..."
858
+ end
859
+ pad_q = [inner_w - display_width(q_clean), 0].max
752
860
  q_left = " " * (pad_q / 2)
753
861
  q_right = " " * (pad_q - (pad_q / 2))
754
862
 
755
863
  lines = []
756
864
  if is_rgb
757
865
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
758
- lines << "#{Color.rgb(v_l)} #{q_left}#{question}#{q_right} #{Color.rgb(v_r)}"
866
+ lines << "#{Color.rgb(v_l)} #{q_left}#{q_clean}#{q_right} #{Color.rgb(v_r)}"
759
867
  lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
760
868
  lines << "#{Color.rgb(v_l)} #{btn_formatted_line} #{Color.rgb(v_r)}"
761
869
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
762
870
  else
763
871
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
764
- lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{question}#{q_right} #{color_code}#{v_r}#{reset_code}"
872
+ lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{q_clean}#{q_right} #{color_code}#{v_r}#{reset_code}"
765
873
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
766
874
  lines << "#{color_code}#{v_l}#{reset_code} #{btn_formatted_line} #{color_code}#{v_r}#{reset_code}"
767
875
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -837,24 +945,31 @@ class GRmenu
837
945
 
838
946
  render_input = lambda do
839
947
  display_str = password ? ("*" * text.length) : text
948
+ avail_inp_w = [inner_w - 4, 4].max
949
+ if display_width(display_str) > avail_inp_w
950
+ display_str = "..." + display_str[-[avail_inp_w - 3, 1].max..-1]
951
+ end
840
952
  input_raw = "> #{display_str}█"
841
- pad_in = [inner_w - display_width(input_raw), 0].max
842
- input_padded = input_raw + (" " * pad_in)
953
+ input_padded = pad_to_width(input_raw, inner_w)
843
954
 
844
- pad_p = [inner_w - display_width(prompt_text), 0].max
955
+ p_clean = prompt_text.to_s
956
+ if display_width(p_clean) > inner_w
957
+ p_clean = p_clean[0...[inner_w - 3, 1].max] + "..."
958
+ end
959
+ pad_p = [inner_w - display_width(p_clean), 0].max
845
960
  p_left = " " * (pad_p / 2)
846
961
  p_right = " " * (pad_p - (pad_p / 2))
847
962
 
848
963
  lines = []
849
964
  if is_rgb
850
965
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
851
- lines << "#{Color.rgb(v_l)} #{Color.bright_yellow(p_left + prompt_text + p_right)} #{Color.rgb(v_r)}"
966
+ lines << "#{Color.rgb(v_l)} #{Color.bright_yellow(p_left + p_clean + p_right)} #{Color.rgb(v_r)}"
852
967
  lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
853
968
  lines << "#{Color.rgb(v_l)} #{Color.bright_white(input_padded)} #{Color.rgb(v_r)}"
854
969
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
855
970
  else
856
971
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
857
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_left + prompt_text + p_right)} #{color_code}#{v_r}#{reset_code}"
972
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_left + p_clean + p_right)} #{color_code}#{v_r}#{reset_code}"
858
973
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
859
974
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_white(input_padded)} #{color_code}#{v_r}#{reset_code}"
860
975
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -903,9 +1018,12 @@ class GRmenu
903
1018
  text
904
1019
  end
905
1020
 
906
- def self.checkbox(items, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: "cyan", style: 3, page_size: 8, min_width: nil, preselected: [])
1021
+ def self.checkbox(items, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: nil, style: nil, page_size: nil, min_width: nil, preselected: [])
1022
+ cb_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "checkbox")) || {}
907
1023
  item_list = items.is_a?(Array) ? items : Array(items)
908
1024
  return [] if item_list.empty?
1025
+ chk_mark = cb_sec["checked_mark"] || "[X]"
1026
+ unchk_mark = cb_sec["unchecked_mark"] || "[ ]"
909
1027
 
910
1028
  parsed_items = item_list.map do |it|
911
1029
  case it
@@ -936,8 +1054,10 @@ class GRmenu
936
1054
  index = 0
937
1055
  rgb_tick = 0.0
938
1056
  drawn_lines = 0
939
- is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
940
- border_cfg = BORDERS[style] || BORDERS[3]
1057
+ cb_color = (color || cb_sec["color"] || "cyan").to_s
1058
+ style_num = (style || cb_sec["style"] || 3).to_i
1059
+ is_rgb = (cb_color.downcase == "rgb" || cb_color.downcase == "rainbow" || cb_color.downcase == "chroma")
1060
+ border_cfg = BORDERS[style_num] || BORDERS[3]
941
1061
  h_top = border_cfg[:ht] || border_cfg[:h]
942
1062
  h_bot = border_cfg[:hb] || border_cfg[:h]
943
1063
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -972,8 +1092,10 @@ class GRmenu
972
1092
  if is_rgb
973
1093
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
974
1094
  unless title.to_s.empty?
975
- pad_t = [inner_w - display_width(title), 0].max
976
- t_line = (" " * (pad_t / 2)) + title + (" " * (pad_t - (pad_t / 2)))
1095
+ t_clean = title.to_s
1096
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1097
+ pad_t = [inner_w - display_width(t_clean), 0].max
1098
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
977
1099
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(t_line, rgb_tick + 0.2)} #{Color.rgb(v_r, rgb_tick)}"
978
1100
  lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
979
1101
  end
@@ -984,11 +1106,13 @@ class GRmenu
984
1106
  end
985
1107
  (start_idx..end_idx).each do |i|
986
1108
  it = parsed_items[i]
987
- mark = it[:checked] ? "[X]" : "[ ]"
1109
+ mark = it[:checked] ? chk_mark : unchk_mark
988
1110
  is_active = (i == index)
989
- raw_line = "#{is_active ? '> ' : ' '}#{mark} #{it[:name]}"
990
- pad_l = [inner_w - display_width(raw_line), 0].max
991
- line_padded = raw_line + (" " * pad_l)
1111
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1112
+ name_str = it[:name].to_s
1113
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1114
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1115
+ line_padded = pad_to_width(raw_line, inner_w)
992
1116
  if is_active
993
1117
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(line_padded, rgb_tick + 0.4)} #{Color.rgb(v_r, rgb_tick)}"
994
1118
  elsif it[:checked]
@@ -1005,18 +1129,22 @@ class GRmenu
1005
1129
  end
1006
1130
  unless subtitle.to_s.empty?
1007
1131
  lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
1008
- pad_sub = [inner_w - display_width(subtitle), 0].max
1009
- sub_padded = (" " * (pad_sub / 2)) + subtitle + (" " * (pad_sub - (pad_sub / 2)))
1132
+ s_clean = subtitle.to_s
1133
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1134
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1135
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1010
1136
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(sub_padded)} #{Color.rgb(v_r, rgb_tick)}"
1011
1137
  end
1012
1138
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1013
1139
  else
1014
- color_code = ansi_color(color, 2)
1140
+ color_code = ansi_color(cb_color, 2)
1015
1141
  reset_code = ansi_reset
1016
1142
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1017
1143
  unless title.to_s.empty?
1018
- pad_t = [inner_w - display_width(title), 0].max
1019
- t_line = (" " * (pad_t / 2)) + title + (" " * (pad_t - (pad_t / 2)))
1144
+ t_clean = title.to_s
1145
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1146
+ pad_t = [inner_w - display_width(t_clean), 0].max
1147
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
1020
1148
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(t_line)} #{color_code}#{v_r}#{reset_code}"
1021
1149
  lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1022
1150
  end
@@ -1027,15 +1155,17 @@ class GRmenu
1027
1155
  end
1028
1156
  (start_idx..end_idx).each do |i|
1029
1157
  it = parsed_items[i]
1030
- mark = it[:checked] ? "[X]" : "[ ]"
1158
+ mark = it[:checked] ? chk_mark : unchk_mark
1031
1159
  is_active = (i == index)
1032
- raw_line = "#{is_active ? '> ' : ' '}#{mark} #{it[:name]}"
1033
- pad_l = [inner_w - display_width(raw_line), 0].max
1034
- line_padded = raw_line + (" " * pad_l)
1160
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1161
+ name_str = it[:name].to_s
1162
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1163
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1164
+ line_padded = pad_to_width(raw_line, inner_w)
1035
1165
  if is_active
1036
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1166
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(line_padded)} #{color_code}#{v_r}#{reset_code}"
1037
1167
  elsif it[:checked]
1038
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1168
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1039
1169
  else
1040
1170
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.white(line_padded)} #{color_code}#{v_r}#{reset_code}"
1041
1171
  end
@@ -1048,8 +1178,10 @@ class GRmenu
1048
1178
  end
1049
1179
  unless subtitle.to_s.empty?
1050
1180
  lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1051
- pad_sub = [inner_w - display_width(subtitle), 0].max
1052
- sub_padded = (" " * (pad_sub / 2)) + subtitle + (" " * (pad_sub - (pad_sub / 2)))
1181
+ s_clean = subtitle.to_s
1182
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1183
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1184
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1053
1185
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(sub_padded)} #{color_code}#{v_r}#{reset_code}"
1054
1186
  end
1055
1187
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -1139,14 +1271,17 @@ class GRmenu
1139
1271
  alias_method :multiselect, :checkbox
1140
1272
  end
1141
1273
 
1142
- def self.slider(prompt = "Selecciona un valor:", min: 0, max: 100, step: 1, default: nil, unit: "", color: "cyan", style: 3, width: 46)
1274
+ def self.slider(prompt = "Selecciona un valor:", min: 0, max: 100, step: 1, default: nil, unit: "", color: nil, style: nil, width: 46)
1275
+ sl_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "slider")) || {}
1143
1276
  val = (default || min).to_f.clamp(min.to_f, max.to_f)
1144
1277
  step_val = [step.to_f, 0.001].max
1145
1278
  drawn_lines = 0
1146
1279
  rgb_tick = 0.0
1147
- is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
1280
+ sl_color = (color || sl_sec["color"] || "cyan").to_s
1281
+ style_num = (style || sl_sec["style"] || 3).to_i
1282
+ is_rgb = (sl_color.downcase == "rgb" || sl_color.downcase == "rainbow" || sl_color.downcase == "chroma")
1148
1283
 
1149
- border_cfg = BORDERS[style] || BORDERS[3]
1284
+ border_cfg = BORDERS[style_num] || BORDERS[3]
1150
1285
  h_top = border_cfg[:ht] || border_cfg[:h]
1151
1286
  h_bot = border_cfg[:hb] || border_cfg[:h]
1152
1287
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -1168,14 +1303,21 @@ class GRmenu
1168
1303
  range_span = 1.0 if range_span <= 0
1169
1304
  fraction = ((val - min).to_f / range_span).clamp(0.0, 1.0)
1170
1305
 
1171
- bar_w = [inner_w - val_str.length - 5, 10].max
1172
- filled_len = (fraction * bar_w).round
1173
- empty_len = bar_w - filled_len
1306
+ avail_bar_w = [inner_w - display_width(val_str) - 4, 6].max
1307
+ filled_len = (fraction * avail_bar_w).round
1308
+ empty_len = [avail_bar_w - filled_len, 0].max
1174
1309
 
1175
- pad_p = [inner_w - display_width(prompt), 0].max
1176
- p_line = (" " * (pad_p / 2)) + prompt + (" " * (pad_p - (pad_p / 2)))
1310
+ p_clean = prompt.to_s
1311
+ if display_width(p_clean) > inner_w
1312
+ p_clean = p_clean[0...[inner_w - 3, 1].max] + "..."
1313
+ end
1314
+ pad_p = [inner_w - display_width(p_clean), 0].max
1315
+ p_line = (" " * (pad_p / 2)) + p_clean + (" " * (pad_p - (pad_p / 2)))
1177
1316
 
1178
- instr = "← / → Ajustar | Enter Guardar"
1317
+ instr = (inner_w >= 30) ? "← / → Ajustar | Enter Guardar" : "←/→: Ajustar | Enter: Ok"
1318
+ if display_width(instr) > inner_w
1319
+ instr = instr[0...[inner_w - 3, 1].max] + "..."
1320
+ end
1179
1321
  pad_i = [inner_w - display_width(instr), 0].max
1180
1322
  i_line = (" " * (pad_i / 2)) + instr + (" " * (pad_i - (pad_i / 2)))
1181
1323
 
@@ -1188,22 +1330,24 @@ class GRmenu
1188
1330
  filled_part = Color.rgb("█" * filled_len, rgb_tick + 0.5)
1189
1331
  empty_part = Color.gray("░" * empty_len)
1190
1332
  bar_raw = "[#{filled_part}#{empty_part}] #{Color.bright_white(val_str)}"
1191
- pad_b = [inner_w - (bar_w + 3 + val_str.length), 0].max
1333
+ bar_vis_w = 2 + filled_len + empty_len + 1 + display_width(val_str)
1334
+ pad_b = [inner_w - bar_vis_w, 0].max
1192
1335
  lines << "#{Color.rgb(v_l, rgb_tick)} #{bar_raw}#{' ' * pad_b} #{Color.rgb(v_r, rgb_tick)}"
1193
1336
  lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1194
1337
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(i_line)} #{Color.rgb(v_r, rgb_tick)}"
1195
1338
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1196
1339
  else
1197
- color_code = ansi_color(color, 2)
1340
+ color_code = ansi_color(sl_color, 2)
1198
1341
  reset_code = ansi_reset
1199
1342
 
1200
1343
  bar_raw = "[#{"█" * filled_len}#{"░" * empty_len}] #{val_str}"
1201
1344
  pad_b = [inner_w - display_width(bar_raw), 0].max
1345
+ bar_line = bar_raw + (" " * pad_b)
1202
1346
 
1203
1347
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1204
1348
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_line)} #{color_code}#{v_r}#{reset_code}"
1205
1349
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1206
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_cyan(bar_raw)}#{' ' * pad_b} #{color_code}#{v_r}#{reset_code}"
1350
+ lines << "#{color_code}#{v_l}#{reset_code} #{bar_line} #{color_code}#{v_r}#{reset_code}"
1207
1351
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1208
1352
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(i_line)} #{color_code}#{v_r}#{reset_code}"
1209
1353
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -1322,7 +1466,8 @@ class GRmenu
1322
1466
  banner: { color: "magenta", level: 2 },
1323
1467
  subtitle: { color: "cyan", level: 2 },
1324
1468
  divider: { color: "blue", level: 1 },
1325
- font: 1
1469
+ font: 1,
1470
+ desc_prefix: "[i]"
1326
1471
  )
1327
1472
  @border = border.dup
1328
1473
  @options = options.dup
@@ -1332,11 +1477,22 @@ class GRmenu
1332
1477
  @subtitle = subtitle.dup
1333
1478
  @divider = divider.dup
1334
1479
  @font = font.to_i
1480
+ @desc_prefix = desc_prefix.to_s
1481
+ end
1482
+
1483
+ def desc_prefix(prefix_str = nil)
1484
+ return @desc_prefix if prefix_str.nil?
1485
+ @desc_prefix = prefix_str.to_s
1486
+ self
1335
1487
  end
1488
+ alias_method :description_prefix, :desc_prefix
1489
+ alias_method :desc_prefix=, :desc_prefix
1490
+ alias_method :description_prefix=, :desc_prefix
1336
1491
 
1337
1492
  def border(color_name = nil, brightness_level = 1)
1338
1493
  return @border if color_name.nil?
1339
1494
  @border = parse_color(color_name, brightness_level)
1495
+ self
1340
1496
  end
1341
1497
  alias_method :Border, :border
1342
1498
  alias_method :set_border, :border
@@ -1345,6 +1501,7 @@ class GRmenu
1345
1501
  def options(color_name = nil, brightness_level = 1)
1346
1502
  return @options if color_name.nil?
1347
1503
  @options = parse_color(color_name, brightness_level)
1504
+ self
1348
1505
  end
1349
1506
  alias_method :Options, :options
1350
1507
  alias_method :set_options, :options
@@ -1353,6 +1510,7 @@ class GRmenu
1353
1510
  def focus(color_name = nil, brightness_level = 2)
1354
1511
  return @focus if color_name.nil?
1355
1512
  @focus = parse_color(color_name, brightness_level)
1513
+ self
1356
1514
  end
1357
1515
  alias_method :Focus, :focus
1358
1516
  alias_method :set_focus, :focus
@@ -1361,6 +1519,7 @@ class GRmenu
1361
1519
  def title(color_name = nil, brightness_level = 2)
1362
1520
  return @title if color_name.nil?
1363
1521
  @title = parse_color(color_name, brightness_level)
1522
+ self
1364
1523
  end
1365
1524
  alias_method :Title, :title
1366
1525
  alias_method :set_title, :title
@@ -1369,6 +1528,7 @@ class GRmenu
1369
1528
  def banner(color_name = nil, brightness_level = 2)
1370
1529
  return @banner if color_name.nil?
1371
1530
  @banner = parse_color(color_name, brightness_level)
1531
+ self
1372
1532
  end
1373
1533
  alias_method :Banner, :banner
1374
1534
  alias_method :set_banner, :banner
@@ -1377,6 +1537,7 @@ class GRmenu
1377
1537
  def subtitle(color_name = nil, brightness_level = 2)
1378
1538
  return @subtitle if color_name.nil?
1379
1539
  @subtitle = parse_color(color_name, brightness_level)
1540
+ self
1380
1541
  end
1381
1542
  alias_method :Subtitle, :subtitle
1382
1543
  alias_method :set_subtitle, :subtitle
@@ -1385,6 +1546,7 @@ class GRmenu
1385
1546
  def divider(color_name = nil, brightness_level = 1)
1386
1547
  return @divider if color_name.nil?
1387
1548
  @divider = parse_color(color_name, brightness_level)
1549
+ self
1388
1550
  end
1389
1551
  alias_method :Divider, :divider
1390
1552
  alias_method :set_divider, :divider
@@ -1393,6 +1555,7 @@ class GRmenu
1393
1555
  def font(font_id = nil)
1394
1556
  return @font if font_id.nil?
1395
1557
  @font = font_id.to_i
1558
+ self
1396
1559
  end
1397
1560
  alias_method :Font, :font
1398
1561
  alias_method :set_font, :font
@@ -1473,6 +1636,15 @@ class GRmenu
1473
1636
  alias_method :Font, :font
1474
1637
  alias_method :set_font, :font
1475
1638
  alias_method :font=, :font
1639
+
1640
+ def desc_prefix(prefix_str = nil)
1641
+ @default_desc_prefix ||= "[i]"
1642
+ return @default_desc_prefix if prefix_str.nil?
1643
+ @default_desc_prefix = prefix_str.to_s
1644
+ end
1645
+ alias_method :description_prefix, :desc_prefix
1646
+ alias_method :desc_prefix=, :desc_prefix
1647
+ alias_method :description_prefix=, :desc_prefix
1476
1648
  end
1477
1649
  end
1478
1650
 
@@ -1524,6 +1696,709 @@ class GRmenu
1524
1696
  alias_method :clr, :clear_screen
1525
1697
  end
1526
1698
 
1699
+ @@global_theme = {}
1700
+
1701
+ def self.current_theme
1702
+ @@global_theme
1703
+ end
1704
+
1705
+ def self.parse_config_text(text)
1706
+ data = { global: {}, sections: {} }
1707
+ current_sec = nil
1708
+ current_sec_data = {}
1709
+
1710
+ text.to_s.each_line do |line|
1711
+ line = line.strip
1712
+ next if line.empty? || line.start_with?("#")
1713
+ next if line.start_with?("GRmenu::config")
1714
+
1715
+ if line.start_with?("<<")
1716
+ sec_name = line[2..-1].strip.downcase
1717
+ current_sec = sec_name
1718
+ current_sec_data = {}
1719
+ elsif line == ">>"
1720
+ if current_sec
1721
+ data[:sections][current_sec] = current_sec_data
1722
+ current_sec = nil
1723
+ end
1724
+ elsif line.include?("::")
1725
+ key, _, val = line.partition("::")
1726
+ key = key.strip.sub(/^@/, '').downcase
1727
+ val = val.strip.sub(/^["']/, '').sub(/["']$/, '')
1728
+ if current_sec
1729
+ current_sec_data[key] = val
1730
+ else
1731
+ data[:global][key] = val
1732
+ end
1733
+ end
1734
+ end
1735
+ data
1736
+ end
1737
+
1738
+ def self.find_theme_file(path_or_name)
1739
+ name = path_or_name.to_s
1740
+ candidates = [
1741
+ name,
1742
+ "#{name}.gr",
1743
+ find_data_file("themes/#{name}.gr"),
1744
+ find_data_file("themes/#{name}"),
1745
+ File.expand_path("data/themes/#{name}.gr", __dir__),
1746
+ File.expand_path("data/themes/#{name}", __dir__),
1747
+ File.expand_path("../data/themes/#{name}.gr", __dir__),
1748
+ File.expand_path("../data/themes/#{name}", __dir__)
1749
+ ].compact
1750
+ candidates.find { |p| File.exist?(p) }
1751
+ end
1752
+
1753
+ def self.import_config(path_or_name)
1754
+ path = find_theme_file(path_or_name)
1755
+ raise "No se encontro el tema: #{path_or_name}" unless path && File.exist?(path)
1756
+ text = File.read(path)
1757
+ parsed = parse_config_text(text)
1758
+ apply_parsed_theme(parsed)
1759
+ @@global_theme = parsed
1760
+ path
1761
+ end
1762
+
1763
+ def self.theme(name)
1764
+ import_config(name)
1765
+ end
1766
+
1767
+ def self.extract_color_and_level(val, default_level = 1)
1768
+ return ["white", default_level] if val.nil?
1769
+ parts = val.to_s.split(":")
1770
+ c_name = parts[0].to_s.strip
1771
+ lvl = parts[1] ? parts[1].to_i : default_level
1772
+ [c_name, lvl]
1773
+ end
1774
+
1775
+ def self.apply_parsed_theme(parsed)
1776
+ sec = parsed[:sections] || {}
1777
+ glob = parsed[:global] || {}
1778
+ m = (sec["menu"] || {}).merge(glob)
1779
+
1780
+ if m && !m.empty?
1781
+ if m["border"] || m["border_color"]
1782
+ c, l = extract_color_and_level(m["border"] || m["border_color"], 1)
1783
+ SetStyle.border(c, l)
1784
+ end
1785
+ if m["title"] || m["title_color"]
1786
+ c, l = extract_color_and_level(m["title"] || m["title_color"], 2)
1787
+ SetStyle.title(c, l)
1788
+ end
1789
+ if m["focus"] || m["focus_color"]
1790
+ c, l = extract_color_and_level(m["focus"] || m["focus_color"], 2)
1791
+ SetStyle.focus(c, l)
1792
+ end
1793
+ if m["options"] || m["options_color"]
1794
+ c, l = extract_color_and_level(m["options"] || m["options_color"], 1)
1795
+ SetStyle.options(c, l)
1796
+ end
1797
+ if m["banner"] || m["banner_color"]
1798
+ c, l = extract_color_and_level(m["banner"] || m["banner_color"], 2)
1799
+ SetStyle.banner(c, l)
1800
+ end
1801
+ if m["subtitle"] || m["subtitle_color"]
1802
+ c, l = extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
1803
+ SetStyle.subtitle(c, l)
1804
+ end
1805
+ if m["divider"] || m["divider_color"]
1806
+ c, l = extract_color_and_level(m["divider"] || m["divider_color"], 1)
1807
+ SetStyle.divider(c, l)
1808
+ end
1809
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
1810
+ SetStyle.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
1811
+ end
1812
+ SetStyle.font(m["font"].to_i) if m["font"]
1813
+ end
1814
+
1815
+ sec.each do |k, v|
1816
+ next unless v.is_a?(Hash)
1817
+ c_val = v["color"] || v["border"] || v["options"] || v["focus"] || v["title"] || v["banner"] || v["subtitle"] || v["divider"]
1818
+ c, l = extract_color_and_level(c_val, (v["level"] || 1).to_i)
1819
+ case k
1820
+ when "border"
1821
+ SetStyle.border(c, l)
1822
+ when "options"
1823
+ SetStyle.options(c, l)
1824
+ when "focus"
1825
+ SetStyle.focus(c, l)
1826
+ when "title"
1827
+ SetStyle.title(c, l)
1828
+ when "banner"
1829
+ SetStyle.banner(c, l)
1830
+ when "subtitle"
1831
+ SetStyle.subtitle(c, l)
1832
+ when "divider"
1833
+ SetStyle.divider(c, l)
1834
+ end
1835
+ end
1836
+ SetStyle.font(glob["font"].to_i) if glob["font"]
1837
+ end
1838
+
1839
+ def self.style(css_content)
1840
+ parsed = parse_config_text(css_content)
1841
+ apply_parsed_theme(parsed)
1842
+ parsed
1843
+ end
1844
+
1845
+ def self.export_config(path = nil)
1846
+ if path.nil?
1847
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
1848
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
1849
+ path = "#{base}.gr"
1850
+ end
1851
+ lines = ["GRmenu::config<-1->", ""]
1852
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
1853
+ lines << "@author:: \"grcode\""
1854
+ lines << "@version:: \"1.0\""
1855
+ lines << ""
1856
+ lines << "<<menu"
1857
+ lines << " style:: 3"
1858
+ lines << " banner_style:: 3"
1859
+ lines << " font:: #{SetStyle.font}"
1860
+ lines << " animate:: rgb"
1861
+ lines << " center:: true"
1862
+ lines << " border:: #{SetStyle.border[:color]}:#{SetStyle.border[:level]}"
1863
+ lines << " title:: #{SetStyle.title[:color]}:#{SetStyle.title[:level]}"
1864
+ lines << " focus:: #{SetStyle.focus[:color]}:#{SetStyle.focus[:level]}"
1865
+ lines << " options:: #{SetStyle.options[:color]}:#{SetStyle.options[:level]}"
1866
+ lines << " banner:: #{SetStyle.banner[:color]}:#{SetStyle.banner[:level]}"
1867
+ lines << " subtitle:: #{SetStyle.subtitle[:color]}:#{SetStyle.subtitle[:level]}"
1868
+ lines << " divider:: #{SetStyle.divider[:color]}:#{SetStyle.divider[:level]}"
1869
+ lines << ">>"
1870
+ lines << ""
1871
+ lines << "<<table"
1872
+ lines << " style:: 3"
1873
+ lines << " header_color:: yellow:2"
1874
+ lines << " border_color:: rgb:2"
1875
+ lines << " selected_row:: green:2"
1876
+ lines << " row_color:: white:1"
1877
+ lines << " zebra_striping:: true"
1878
+ lines << ">>"
1879
+ lines << ""
1880
+ lines << "<<card"
1881
+ lines << " style:: 7"
1882
+ lines << " border_color:: cyan:2"
1883
+ lines << " title_color:: yellow:2"
1884
+ lines << " content_color:: white:1"
1885
+ lines << ">>"
1886
+ lines << ""
1887
+ lines << "<<slider"
1888
+ lines << " style:: 3"
1889
+ lines << " color:: rgb:2"
1890
+ lines << " fill_char:: █"
1891
+ lines << " empty_char:: ░"
1892
+ lines << ">>"
1893
+ lines << ""
1894
+ lines << "<<checkbox"
1895
+ lines << " style:: 3"
1896
+ lines << " color:: rgb:2"
1897
+ lines << " checked_mark:: [X]"
1898
+ lines << " unchecked_mark:: [ ]"
1899
+ lines << ">>"
1900
+ lines << ""
1901
+ File.write(path, lines.join("\n") + "\n")
1902
+ path
1903
+ end
1904
+
1905
+ def self.export_from_file(source_file, target_path = nil)
1906
+ raise "No existe #{source_file}" unless File.exist?(source_file)
1907
+ orig_draw = instance_method(:draw) rescue nil
1908
+ extracted = nil
1909
+ define_method(:draw) do |*|
1910
+ extracted = {
1911
+ style: @style,
1912
+ banner_style: @banner_style,
1913
+ font: @style_config&.font,
1914
+ animate: @animate,
1915
+ border: @style_config&.border,
1916
+ title: @style_config&.title,
1917
+ focus: @style_config&.focus,
1918
+ options: @style_config&.options,
1919
+ banner: @style_config&.banner,
1920
+ subtitle: @style_config&.subtitle,
1921
+ divider: @style_config&.divider
1922
+ }
1923
+ throw :grmenu_export_completed
1924
+ end
1925
+ begin
1926
+ catch(:grmenu_export_completed) do
1927
+ load(File.expand_path(source_file))
1928
+ end
1929
+ ensure
1930
+ define_method(:draw, orig_draw) if orig_draw
1931
+ end
1932
+ out = target_path || source_file.sub(/\.rb$/, '') + ".gr"
1933
+ if extracted && extracted[:border]
1934
+ lines = ["GRmenu::config<-1->", ""]
1935
+ lines << "@theme:: \"#{File.basename(out, '.gr').capitalize}\""
1936
+ lines << "@author:: \"grcode\""
1937
+ lines << "@version:: \"1.0\""
1938
+ lines << ""
1939
+ lines << "<<menu"
1940
+ lines << " style:: #{extracted[:style] || 3}"
1941
+ lines << " banner_style:: #{extracted[:banner_style] || 3}"
1942
+ lines << " font:: #{extracted[:font] || 1}"
1943
+ lines << " animate:: #{extracted[:animate] || 'rgb'}"
1944
+ lines << " center:: true"
1945
+ lines << " border:: #{extracted[:border][:color]}:#{extracted[:border][:level]}"
1946
+ lines << " title:: #{extracted[:title][:color]}:#{extracted[:title][:level]}"
1947
+ lines << " focus:: #{extracted[:focus][:color]}:#{extracted[:focus][:level]}"
1948
+ lines << " options:: #{extracted[:options][:color]}:#{extracted[:options][:level]}"
1949
+ lines << " banner:: #{extracted[:banner][:color]}:#{extracted[:banner][:level]}"
1950
+ lines << " subtitle:: #{extracted[:subtitle][:color]}:#{extracted[:subtitle][:level]}"
1951
+ lines << " divider:: #{extracted[:divider][:color]}:#{extracted[:divider][:level]}"
1952
+ lines << ">>"
1953
+ lines << ""
1954
+ lines << "<<table"
1955
+ lines << " style:: #{extracted[:style] || 3}"
1956
+ lines << " header_color:: yellow:2"
1957
+ lines << " border_color:: rgb:2"
1958
+ lines << " selected_row:: green:2"
1959
+ lines << " row_color:: white:1"
1960
+ lines << " zebra_striping:: true"
1961
+ lines << ">>"
1962
+ lines << ""
1963
+ lines << "<<card"
1964
+ lines << " style:: 7"
1965
+ lines << " border_color:: cyan:2"
1966
+ lines << " title_color:: yellow:2"
1967
+ lines << " content_color:: white:1"
1968
+ lines << ">>"
1969
+ lines << ""
1970
+ lines << "<<slider"
1971
+ lines << " style:: 3"
1972
+ lines << " color:: rgb:2"
1973
+ lines << " fill_char:: █"
1974
+ lines << " empty_char:: ░"
1975
+ lines << ">>"
1976
+ lines << ""
1977
+ lines << "<<checkbox"
1978
+ lines << " style:: 3"
1979
+ lines << " color:: rgb:2"
1980
+ lines << " checked_mark:: [X]"
1981
+ lines << " unchecked_mark:: [ ]"
1982
+ lines << ">>"
1983
+ lines << ""
1984
+ File.write(out, lines.join("\n") + "\n")
1985
+ out
1986
+ else
1987
+ export_config(out)
1988
+ end
1989
+ end
1990
+
1991
+ def self.split_ansi_chars(str)
1992
+ segments = []
1993
+ current_style = String.new("")
1994
+ in_escape = false
1995
+ escape_buf = String.new("")
1996
+
1997
+ str.to_s.each_char do |ch|
1998
+ if ch == "\e"
1999
+ in_escape = true
2000
+ escape_buf << ch
2001
+ next
2002
+ end
2003
+ if in_escape
2004
+ escape_buf << ch
2005
+ if ch =~ /[a-zA-Z]/
2006
+ in_escape = false
2007
+ current_style = escape_buf.dup
2008
+ escape_buf.clear
2009
+ end
2010
+ next
2011
+ end
2012
+ segments << { char: ch, style: current_style.dup }
2013
+ end
2014
+ segments
2015
+ end
2016
+
2017
+ def self.animate_render(lines, type = :diagonal, delay = 0.012)
2018
+ type_str = type.to_s.downcase
2019
+ return if lines.nil? || lines.empty?
2020
+ rst = ansi_reset
2021
+
2022
+ case type_str
2023
+ when "diagonal"
2024
+ parsed_rows = lines.map { |l| split_ansi_chars(l) }
2025
+ max_len = parsed_rows.map(&:length).max || 0
2026
+ total_steps = max_len + (parsed_rows.length * 2)
2027
+ step = 0
2028
+ while step <= total_steps
2029
+ buffer = String.new(CURSOR_HOME)
2030
+ parsed_rows.each_with_index do |row_segs, y|
2031
+ rendered_row = String.new("")
2032
+ row_segs.each_with_index do |seg, x|
2033
+ if (x + y * 2) <= step
2034
+ rendered_row << seg[:style] << seg[:char] << rst
2035
+ else
2036
+ rendered_row << " "
2037
+ end
2038
+ end
2039
+ buffer << rendered_row << CLEAR_TO_EOL << "\r\n"
2040
+ end
2041
+ buffer << CLEAR_TO_EOS
2042
+ Kernel.print(buffer)
2043
+ $stdout.flush
2044
+ sleep(delay)
2045
+ step += 4
2046
+ end
2047
+ when "linear"
2048
+ buffer = String.new(CURSOR_HOME)
2049
+ lines.each do |line|
2050
+ Kernel.print("#{line}#{CLEAR_TO_EOL}\r\n")
2051
+ $stdout.flush
2052
+ sleep(delay * 3)
2053
+ end
2054
+ when "fade"
2055
+ [1, 2].each do |lvl|
2056
+ buffer = String.new(CURSOR_HOME)
2057
+ lines.each do |line|
2058
+ clean = line.gsub(/\e\[[0-9;]*m/, '')
2059
+ buffer << ansi_color("white", lvl) << clean << rst << CLEAR_TO_EOL << "\r\n"
2060
+ end
2061
+ buffer << CLEAR_TO_EOS
2062
+ Kernel.print(buffer)
2063
+ $stdout.flush
2064
+ sleep(delay * 8)
2065
+ end
2066
+ end
2067
+ end
2068
+
2069
+ def self.alert(type, message, title: nil, style: 3, color: nil, border_color: nil, title_color: nil, pause: true)
2070
+ type_sym = type.to_sym rescue :info
2071
+ tag, def_col, def_title = case type_sym
2072
+ when :success, :ok
2073
+ ["[✔ EXITO]", "green", "Operacion Exitosa"]
2074
+ when :error, :fail, :danger
2075
+ ["[✖ ERROR]", "red", "Error en el Sistema"]
2076
+ when :warning, :warn
2077
+ ["[⚠ AVISO]", "yellow", "Advertencia"]
2078
+ else
2079
+ ["[ℹ INFO]", "cyan", "Informacion"]
2080
+ end
2081
+ card_col = border_color || color || def_col
2082
+ card_title = title || "#{tag} #{def_title}"
2083
+ card(title: card_title, content: message, style: style, color: card_col, title_color: title_color, pause: pause)
2084
+ end
2085
+
2086
+ def self.card(title: nil, content: "", style: nil, color: nil, border_color: nil, title_color: nil, content_color: nil, width: nil, pause: true)
2087
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "card")) || {}
2088
+ style_num = (style || c_sec["style"] || 7).to_i
2089
+ border_cfg = BORDERS[style_num] || BORDERS[7]
2090
+ card_color = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
2091
+ title_color = (title_color || c_sec["title_color"] || "yellow").to_s
2092
+ content_color = (content_color || c_sec["content_color"] || "white").to_s
2093
+ is_rgb = card_color.downcase == "rgb" || card_color.downcase == "rainbow" || card_color.downcase == "chroma"
2094
+
2095
+ lines = content.to_s.split("\n")
2096
+ content_max = lines.map { |l| display_width(l) }.max || 0
2097
+ box_w = width || [content_max + 6, title ? display_width(title) + 6 : 0, 46].max
2098
+ box_w = [box_w, terminal_width - 2].min
2099
+ inner_w = box_w - 2
2100
+
2101
+ wrapped_lines = []
2102
+ lines.each do |raw_l|
2103
+ if display_width(raw_l) <= (inner_w - 2)
2104
+ wrapped_lines << raw_l
2105
+ else
2106
+ cur = String.new("")
2107
+ raw_l.split(" ").each do |w|
2108
+ if cur.empty?
2109
+ cur << w
2110
+ elsif display_width("#{cur} #{w}") <= (inner_w - 2)
2111
+ cur << " " << w
2112
+ else
2113
+ wrapped_lines << cur
2114
+ cur = String.new(w)
2115
+ end
2116
+ end
2117
+ wrapped_lines << cur unless cur.empty?
2118
+ end
2119
+ end
2120
+
2121
+ tl = border_cfg[:tl] || "#"
2122
+ tr = border_cfg[:tr] || "#"
2123
+ bl = border_cfg[:bl] || "#"
2124
+ br = border_cfg[:br] || "#"
2125
+ h_char = border_cfg[:h] || "─"
2126
+ v_char = border_cfg[:v] || "│"
2127
+
2128
+ brd_col = is_rgb ? Color.rgb("").sub(/\e\[0m$/, '') : ansi_color(card_color, 1)
2129
+ rst = ansi_reset
2130
+
2131
+ top_str = if title && !title.empty?
2132
+ t_clean = " #{title} "
2133
+ t_len = display_width(t_clean)
2134
+ if t_len > inner_w
2135
+ t_clean = " #{title[0...[inner_w - 6, 1].max]}... "
2136
+ t_len = display_width(t_clean)
2137
+ end
2138
+ l_len = [(inner_w - t_len) / 2, 0].max
2139
+ r_len = [inner_w - t_len - l_len, 0].max
2140
+ h_char * l_len + ansi_color(title_color, 2) + t_clean + brd_col + h_char * r_len
2141
+ else
2142
+ h_char * inner_w
2143
+ end
2144
+
2145
+ out = +""
2146
+ out << "#{brd_col}#{tl}#{top_str}#{tr}#{rst}\r\n"
2147
+ wrapped_lines.each do |line|
2148
+ pad_line = " " + line
2149
+ out << "#{brd_col}#{v_char}#{rst}#{ansi_color(content_color, 1)}#{pad_to_width(pad_line, inner_w)}#{rst}#{brd_col}#{v_char}#{rst}\r\n"
2150
+ end
2151
+ out << "#{brd_col}#{bl}#{h_char * inner_w}#{br}#{rst}\r\n"
2152
+
2153
+ Kernel.print(out)
2154
+ self.continue if pause
2155
+ end
2156
+
2157
+ def self.table(headers: [], rows: [], title: nil, style: nil, color: nil, page_size: nil, search: false, sort: false, animate: nil, width: nil)
2158
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "table")) || {}
2159
+ input_stream = STDIN
2160
+ output_stream = STDOUT
2161
+ style_num = (style || t_sec["style"] || 3).to_i
2162
+ border_cfg = BORDERS[style_num] || BORDERS[3]
2163
+ tbl_color = (color || t_sec["border_color"] || t_sec["border"] || t_sec["color"] || "cyan").to_s
2164
+ header_color = t_sec["header_color"] || "yellow"
2165
+ focus_color = t_sec["selected_row"] || t_sec["focus"] || "green"
2166
+ page_size ||= (t_sec["page_size"] || 8).to_i
2167
+
2168
+ headers = headers.map(&:to_s)
2169
+ raw_rows = rows.map { |r| r.is_a?(Array) ? r.map(&:to_s) : r.values.map(&:to_s) }
2170
+ filtered_rows = raw_rows.dup
2171
+ selected_idx = 0
2172
+ query = String.new("")
2173
+ sort_col = nil
2174
+ sort_asc = true
2175
+ tick = 0.0
2176
+
2177
+ calc_widths = lambda do
2178
+ col_counts = [headers.length, raw_rows.map(&:length).max || 0].max
2179
+ widths = Array.new(col_counts, 0)
2180
+ headers.each_with_index { |h, i| widths[i] = [widths[i], display_width(h)].max }
2181
+ filtered_rows.each do |row|
2182
+ row.each_with_index { |cell, i| widths[i] = [widths[i], display_width(cell)].max }
2183
+ end
2184
+ widths.map { |w| w + 2 }
2185
+ end
2186
+
2187
+ draw_table = lambda do |t_tick|
2188
+ is_rgb = tbl_color.downcase == "rgb" || tbl_color.downcase == "rainbow" || tbl_color.downcase == "chroma"
2189
+ brd_color = is_rgb ? rgb_color(t_tick, 0.0) : ansi_color(tbl_color, 1)
2190
+ hdr_color = is_rgb ? rgb_color(t_tick, 0.8) : ansi_color(header_color, 2)
2191
+ foc_color = is_rgb ? rgb_color(t_tick, 1.4) : ansi_color(focus_color, 2)
2192
+ rst = ansi_reset
2193
+
2194
+ col_w = calc_widths.call
2195
+ help_line = " ↑/↓: Moverse | Enter: Elegir | s: Ordenar | Esc: Salir"
2196
+ tot_w = [col_w.sum + (col_w.length - 1) + 4, title ? display_width(title) + 8 : 0, display_width(help_line) + 4, 46].max
2197
+ tot_w = [tot_w, terminal_width - 2].min
2198
+ inner_w = tot_w - 2
2199
+
2200
+ if inner_w < display_width(help_line)
2201
+ help_line = " ↑/↓: Mover | Enter: Ok | Esc: Salir"
2202
+ end
2203
+
2204
+ tl = border_cfg[:tl] || "#"
2205
+ tr = border_cfg[:tr] || "#"
2206
+ bl = border_cfg[:bl] || "#"
2207
+ br = border_cfg[:br] || "#"
2208
+ h_char = border_cfg[:h] || "─"
2209
+ v_char = border_cfg[:v] || "│"
2210
+
2211
+ top_str = if title && !title.empty?
2212
+ t_clean = " #{title} "
2213
+ t_len = display_width(t_clean)
2214
+ if t_len > inner_w
2215
+ t_clean = " #{title[0...[(inner_w - 6), 1].max]}... "
2216
+ t_len = display_width(t_clean)
2217
+ end
2218
+ left_len = [(inner_w - t_len) / 2, 0].max
2219
+ right_len = [inner_w - t_len - left_len, 0].max
2220
+ h_char * left_len + t_clean + h_char * right_len
2221
+ else
2222
+ h_char * inner_w
2223
+ end
2224
+
2225
+ out = String.new(CURSOR_HOME)
2226
+ out << HIDE_CURSOR
2227
+ out << "#{brd_color}#{tl}#{top_str}#{tr}#{rst}#{CLEAR_TO_EOL}\r\n"
2228
+
2229
+ if search
2230
+ s_line = " Buscar: #{query}█"
2231
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(s_line, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2232
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2233
+ end
2234
+
2235
+ hdr_cells = headers.each_with_index.map do |h, i|
2236
+ w = col_w[i] || 10
2237
+ sort_indicator = sort_col == i ? (sort_asc ? " ▲" : " ▼") : ""
2238
+ h_str = "#{h}#{sort_indicator}"
2239
+ max_c = [w - 2, 2].max
2240
+ h_str = h_str[0...[max_c - 2, 1].max] + ".." if display_width(h_str) > max_c
2241
+ pad_to_width(" #{h_str}", w)
2242
+ end
2243
+ hdr_row_str = " " + hdr_cells.join("│")
2244
+ hdr_row_str = hdr_row_str[0...inner_w] if display_width(hdr_row_str) > inner_w
2245
+ out << "#{brd_color}#{v_char}#{rst}#{hdr_color}#{pad_to_width(hdr_row_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2246
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2247
+
2248
+ max_visible = page_size || 8
2249
+ total_rows = filtered_rows.length
2250
+ if total_rows == 0
2251
+ empty_msg = " (Sin registros que coincidan con '#{query}')"
2252
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(empty_msg, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2253
+ else
2254
+ start_idx = [(selected_idx - max_visible / 2), 0].max
2255
+ start_idx = [start_idx, [total_rows - max_visible, 0].max].min
2256
+ end_idx = [start_idx + max_visible - 1, total_rows - 1].min
2257
+
2258
+ if start_idx > 0
2259
+ up_str = " ▲ (+#{start_idx} arriba)"
2260
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(up_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2261
+ end
2262
+
2263
+ (start_idx..end_idx).each do |r_i|
2264
+ row = filtered_rows[r_i]
2265
+ is_active = (r_i == selected_idx)
2266
+ prefix = is_active ? "> " : " "
2267
+
2268
+ row_cells = row.each_with_index.map do |cell, c_i|
2269
+ w = col_w[c_i] || 10
2270
+ c_str = cell.to_s
2271
+ max_c = [w - 2, 2].max
2272
+ c_str = c_str[0...[max_c - 2, 1].max] + ".." if display_width(c_str) > max_c
2273
+ pad_to_width(" #{c_str}", w)
2274
+ end
2275
+ row_str = prefix + row_cells.join("│")[1..-1].to_s
2276
+ row_str = row_str[0...inner_w] if display_width(row_str) > inner_w
2277
+
2278
+ if is_active
2279
+ out << "#{brd_color}#{v_char}#{rst}#{foc_color}#{pad_to_width(row_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2280
+ else
2281
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(row_str, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2282
+ end
2283
+ end
2284
+
2285
+ remaining_down = total_rows - 1 - end_idx
2286
+ if remaining_down > 0
2287
+ down_str = " ▼ (+#{remaining_down} abajo)"
2288
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(down_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2289
+ end
2290
+ end
2291
+
2292
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2293
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(help_line, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2294
+ out << "#{brd_color}#{bl}#{h_char * inner_w}#{br}#{rst}#{CLEAR_TO_EOL}\r\n"
2295
+ out << CLEAR_TO_EOS
2296
+ output_stream.print(out)
2297
+ output_stream.flush
2298
+ end
2299
+
2300
+ loop_res = nil
2301
+ reader = lambda do |stream|
2302
+ loop do
2303
+ draw_table.call(tick)
2304
+ is_anim = color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
2305
+ if is_anim
2306
+ ready = false
2307
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
2308
+ begin
2309
+ res = IO.select([stream], nil, nil, 0.035)
2310
+ ready = true if res && res[0] && !res[0].empty?
2311
+ rescue StandardError
2312
+ ready = true
2313
+ end
2314
+ else
2315
+ ready = true
2316
+ end
2317
+ unless ready
2318
+ tick += 0.08
2319
+ next
2320
+ end
2321
+ end
2322
+
2323
+ key = read_key_raw(stream)
2324
+ break if key.nil? || key == "\x03" || key == "\x04"
2325
+
2326
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2327
+ if filtered_rows.length > 0
2328
+ selected_idx = (selected_idx - 1) % filtered_rows.length
2329
+ end
2330
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
2331
+ if filtered_rows.length > 0
2332
+ selected_idx = (selected_idx + 1) % filtered_rows.length
2333
+ end
2334
+ elsif key == "\e[5~" || key == "\e[D"
2335
+ if filtered_rows.length > 0
2336
+ selected_idx = [(selected_idx - (page_size || 8)), 0].max
2337
+ end
2338
+ elsif key == "\e[6~" || key == "\e[C"
2339
+ if filtered_rows.length > 0
2340
+ selected_idx = [(selected_idx + (page_size || 8)), filtered_rows.length - 1].min
2341
+ end
2342
+ elsif key == "\r" || key == "\n"
2343
+ if filtered_rows.length > 0
2344
+ loop_res = filtered_rows[selected_idx]
2345
+ end
2346
+ break
2347
+ elsif key == "\e"
2348
+ if search && !query.empty?
2349
+ query.clear
2350
+ filtered_rows = raw_rows.dup
2351
+ selected_idx = 0
2352
+ else
2353
+ loop_res = nil
2354
+ break
2355
+ end
2356
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
2357
+ if search && !query.empty?
2358
+ query.chop!
2359
+ if query.empty?
2360
+ filtered_rows = raw_rows.dup
2361
+ else
2362
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2363
+ end
2364
+ selected_idx = 0
2365
+ end
2366
+ elsif key == "\x15"
2367
+ if search
2368
+ query.clear
2369
+ filtered_rows = raw_rows.dup
2370
+ selected_idx = 0
2371
+ end
2372
+ elsif (!search || query.empty?) && (key == "s" || key == "S")
2373
+ if sort
2374
+ sort_col = ((sort_col || -1) + 1) % [headers.length, 1].max
2375
+ filtered_rows.sort_by! { |r| r[sort_col] || "" }
2376
+ selected_idx = 0
2377
+ end
2378
+ elsif (!search || query.empty?) && (key == "q" || key == "Q")
2379
+ loop_res = nil
2380
+ break
2381
+ elsif search && key =~ /^[[:print:]]$/
2382
+ query << key
2383
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2384
+ selected_idx = 0
2385
+ end
2386
+ end
2387
+ end
2388
+
2389
+ begin
2390
+ output_stream.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2391
+ if input_stream.respond_to?(:raw) && input_stream.respond_to?(:tty?) && input_stream.tty?
2392
+ input_stream.raw { |s| reader.call(s) }
2393
+ else
2394
+ reader.call(input_stream)
2395
+ end
2396
+ ensure
2397
+ output_stream.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2398
+ end
2399
+ loop_res
2400
+ end
2401
+
1527
2402
  def self.div(long = nil, color = "blue", level = 1, char = "─")
1528
2403
  width = long || [terminal_width - 2, 64].min
1529
2404
  if color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
@@ -1660,14 +2535,21 @@ class GRmenu
1660
2535
  @banner = (banner || keyword_arguments[:banner] || "").to_s
1661
2536
  @subtitle = (subtitle || description || keyword_arguments[:subtitle] || keyword_arguments[:description] || "").to_s
1662
2537
  @divider = divider.nil? ? (!@banner.empty? || !@subtitle.empty?) : divider
1663
- @style = (style || pos_style || keyword_arguments[:style] || 19).to_i
1664
- @banner_style = (banner_style || keyword_arguments[:banner_style] || 3).to_i
1665
- @center = center.nil? ? true : center
2538
+
2539
+ theme_menu_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu')) || {}
2540
+ th_style = theme_menu_sec['style']&.to_i
2541
+ th_bstyle = theme_menu_sec['banner_style']&.to_i
2542
+
2543
+ @style = (style || pos_style || keyword_arguments[:style] || th_style || 19).to_i
2544
+ @banner_style = (banner_style || keyword_arguments[:banner_style] || th_bstyle || 3).to_i
2545
+ @center = center.nil? ? (theme_menu_sec.key?('center') ? (theme_menu_sec['center'].to_s != 'false') : true) : center
1666
2546
  @page_size = (page_size || keyword_arguments[:page_size])&.to_i
1667
2547
  @search = search || keyword_arguments[:search] || false
1668
2548
  @columns = [(columns || keyword_arguments[:columns] || 1).to_i, 1].max
1669
2549
  @image = image || keyword_arguments[:image]
1670
2550
  @image_width = (image_width || keyword_arguments[:image_width])&.to_i
2551
+ @animate = (keyword_arguments[:animate] || (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu', 'animate')) || false).to_s
2552
+ @desc_prefix = keyword_arguments[:desc_prefix] || keyword_arguments[:description_prefix]
1671
2553
  @query = String.new("")
1672
2554
  @index = 0
1673
2555
  @rgb_tick = 0.0
@@ -1676,6 +2558,7 @@ class GRmenu
1676
2558
  @cached_image_cols = nil
1677
2559
 
1678
2560
  init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
2561
+ init_pfx = @desc_prefix || (@@global_theme.is_a?(Hash) && (@@global_theme.dig(:sections, 'menu', 'desc_prefix') || @@global_theme.dig(:sections, 'menu', 'prefix'))) || SetStyle.desc_prefix || "[i]"
1679
2562
 
1680
2563
  @style_config = SetStyle.new(
1681
2564
  border: SetStyle.border.dup,
@@ -1685,7 +2568,8 @@ class GRmenu
1685
2568
  banner: SetStyle.banner.dup,
1686
2569
  subtitle: SetStyle.subtitle.dup,
1687
2570
  divider: SetStyle.divider.dup,
1688
- font: init_font
2571
+ font: init_font,
2572
+ desc_prefix: init_pfx
1689
2573
  )
1690
2574
  end
1691
2575
 
@@ -1776,10 +2660,20 @@ class GRmenu
1776
2660
  def colorize(text, color_config, phase_offset = 0.0)
1777
2661
  return text.to_s if color_config.nil? || color_config.empty?
1778
2662
 
1779
- color_name = (color_config[:color] || color_config["color"]).to_s.downcase
2663
+ color_name = (color_config[:color] || color_config["color"]).to_s.downcase.strip
1780
2664
  brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
1781
2665
 
1782
- if color_name == "rgb" || color_name == "rainbow" || color_name == "chroma"
2666
+ if color_name.include?(":")
2667
+ parts = color_name.split(":")
2668
+ color_name = parts[0].strip
2669
+ brightness_level = parts[1].to_i if parts[1] && !parts[1].empty?
2670
+ end
2671
+
2672
+ is_neon_color = color_name.start_with?("neon")
2673
+ is_anim_active = is_neon_color || (@animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase))
2674
+ is_chroma = color_name == "rgb" || color_name == "rainbow" || color_name == "chroma" || @animate.to_s.downcase == "rgb"
2675
+
2676
+ if is_chroma
1783
2677
  tick = @rgb_tick || 0.0
1784
2678
  out = String.new("")
1785
2679
  char_count = 0
@@ -1814,6 +2708,67 @@ class GRmenu
1814
2708
  return out
1815
2709
  end
1816
2710
 
2711
+ if is_anim_active
2712
+ tick = @rgb_tick || 0.0
2713
+ base = if color_name =~ /\A#?([0-9a-f]{6})\z/i
2714
+ h = $1
2715
+ [h[0..1].to_i(16), h[2..3].to_i(16), h[4..5].to_i(16)]
2716
+ elsif color_name =~ /\A#?([0-9a-f]{3})\z/i
2717
+ h = $1
2718
+ [(h[0] * 2).to_i(16), (h[1] * 2).to_i(16), (h[2] * 2).to_i(16)]
2719
+ else
2720
+ BASE_RGB[color_name] || [255, 255, 255]
2721
+ end
2722
+
2723
+ if @animate.to_s.downcase == "fade"
2724
+ factor = (Math.sin(tick + phase_offset) + 1.0) / 2.0
2725
+ f = 0.35 + 0.65 * factor
2726
+ r = (base[0] * f).clamp(0, 255).to_i
2727
+ g = (base[1] * f).clamp(0, 255).to_i
2728
+ b = (base[2] * f).clamp(0, 255).to_i
2729
+ glow = (factor > 0.85) ? ";1" : ""
2730
+ return "\e[38;2;#{r};#{g};#{b}#{glow}m#{text}#{self.class.ansi_reset}"
2731
+ else
2732
+ out = String.new("")
2733
+ char_count = 0
2734
+ in_escape = false
2735
+ escape_buf = String.new("")
2736
+
2737
+ text.to_s.each_char do |ch|
2738
+ if ch == "\e"
2739
+ in_escape = true
2740
+ escape_buf << ch
2741
+ next
2742
+ end
2743
+ if in_escape
2744
+ escape_buf << ch
2745
+ if ch =~ /[a-zA-Z]/
2746
+ in_escape = false
2747
+ out << escape_buf
2748
+ escape_buf.clear
2749
+ end
2750
+ next
2751
+ end
2752
+
2753
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
2754
+ out << ch
2755
+ else
2756
+ phase = char_count * 0.22 + phase_offset
2757
+ factor = (Math.sin(tick + phase) + 1.0) / 2.0
2758
+ f = 0.35 + 0.65 * factor
2759
+ r = (base[0] * f).clamp(0, 255).to_i
2760
+ g = (base[1] * f).clamp(0, 255).to_i
2761
+ b = (base[2] * f).clamp(0, 255).to_i
2762
+ glow = (factor > 0.85) ? ";1" : ""
2763
+ out << "\e[38;2;#{r};#{g};#{b}#{glow}m#{ch}"
2764
+ char_count += 1
2765
+ end
2766
+ end
2767
+ out << self.class.ansi_reset
2768
+ return out
2769
+ end
2770
+ end
2771
+
1817
2772
  color_code = self.class.ansi_color(color_name, brightness_level)
1818
2773
  return text.to_s unless color_code
1819
2774
 
@@ -1870,7 +2825,7 @@ class GRmenu
1870
2825
 
1871
2826
  lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
1872
2827
  lines << colorize("#{v_l} #{l_p}#{clean_b}#{r_p} #{v_r}", banner_color_cfg, 0.4)
1873
- lines << colorize("#{banner_border[:bl]}#{bot_fill}#{border_cfg[:br]}", banner_color_cfg, 0.8)
2828
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.8)
1874
2829
  end
1875
2830
  [lines, box_w]
1876
2831
  end
@@ -2104,7 +3059,9 @@ class GRmenu
2104
3059
  unless active_desc.empty?
2105
3060
  separator_line = v_l_raw + mid_fill + v_r_raw
2106
3061
  rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 1.0)}"
2107
- raw_desc = "* #{active_desc}"
3062
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3063
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3064
+ raw_desc = "#{pfx}#{active_desc}"
2108
3065
  pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2109
3066
  desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
2110
3067
  rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
@@ -2184,7 +3141,9 @@ class GRmenu
2184
3141
 
2185
3142
  unless active_desc.empty?
2186
3143
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
2187
- raw_desc = "* #{active_desc}"
3144
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3145
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3146
+ raw_desc = "#{pfx}#{active_desc}"
2188
3147
  pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2189
3148
  desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
2190
3149
  rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
@@ -2216,7 +3175,153 @@ class GRmenu
2216
3175
  end
2217
3176
  end
2218
3177
 
3178
+ def has_active_animation?
3179
+ return true if @animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase)
3180
+ return true if has_rgb_animation?
3181
+ configs = [
3182
+ @style_config.border,
3183
+ @style_config.options,
3184
+ @style_config.focus,
3185
+ @style_config.title,
3186
+ @style_config.banner,
3187
+ @style_config.subtitle,
3188
+ @style_config.divider
3189
+ ]
3190
+ configs.any? do |c|
3191
+ if c.is_a?(Hash)
3192
+ val = (c[:color] || c["color"]).to_s.downcase.strip
3193
+ val.start_with?("neon")
3194
+ else
3195
+ false
3196
+ end
3197
+ end
3198
+ end
3199
+
3200
+ def style(css_content)
3201
+ parsed = self.class.parse_config_text(css_content)
3202
+ m = ((parsed[:sections] && parsed[:sections]["menu"]) || {}).merge(parsed[:global] || {})
3203
+ if m["style"]
3204
+ @style = m["style"].to_i
3205
+ @border_config = BORDERS[@style] || BORDERS[3]
3206
+ end
3207
+ @banner_style = m["banner_style"].to_i if m["banner_style"]
3208
+ @animate = m["animate"].to_s if m["animate"]
3209
+ @center = (m["center"].to_s != "false") if m.key?("center")
3210
+ if m["border"] || m["border_color"]
3211
+ c, l = self.class.extract_color_and_level(m["border"] || m["border_color"], 1)
3212
+ @style_config.border(c, l)
3213
+ end
3214
+ if m["options"] || m["options_color"]
3215
+ c, l = self.class.extract_color_and_level(m["options"] || m["options_color"], 1)
3216
+ @style_config.options(c, l)
3217
+ end
3218
+ if m["focus"] || m["focus_color"]
3219
+ c, l = self.class.extract_color_and_level(m["focus"] || m["focus_color"], 2)
3220
+ @style_config.focus(c, l)
3221
+ end
3222
+ if m["title"] || m["title_color"]
3223
+ c, l = self.class.extract_color_and_level(m["title"] || m["title_color"], 2)
3224
+ @style_config.title(c, l)
3225
+ end
3226
+ if m["banner"] || m["banner_color"]
3227
+ c, l = self.class.extract_color_and_level(m["banner"] || m["banner_color"], 2)
3228
+ @style_config.banner(c, l)
3229
+ end
3230
+ if m["subtitle"] || m["subtitle_color"]
3231
+ c, l = self.class.extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
3232
+ @style_config.subtitle(c, l)
3233
+ end
3234
+ if m["divider"] || m["divider_color"]
3235
+ c, l = self.class.extract_color_and_level(m["divider"] || m["divider_color"], 1)
3236
+ @style_config.divider(c, l)
3237
+ end
3238
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
3239
+ @style_config.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
3240
+ end
3241
+ @style_config.font(m["font"].to_i) if m["font"]
3242
+ self
3243
+ end
3244
+
3245
+ def export_config(path = nil)
3246
+ if path.nil?
3247
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
3248
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
3249
+ path = "#{base}.gr"
3250
+ end
3251
+ b_cfg = @style_config&.border || SetStyle.border
3252
+ t_cfg = @style_config&.title || SetStyle.title
3253
+ f_cfg = @style_config&.focus || SetStyle.focus
3254
+ o_cfg = @style_config&.options || SetStyle.options
3255
+ bn_cfg = @style_config&.banner || SetStyle.banner
3256
+ s_cfg = @style_config&.subtitle || SetStyle.subtitle
3257
+ d_cfg = @style_config&.divider || SetStyle.divider
3258
+ dp_val = @style_config&.desc_prefix || SetStyle.desc_prefix
3259
+
3260
+ lines = ["GRmenu::config<-1->", ""]
3261
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
3262
+ lines << "@author:: \"grcode\""
3263
+ lines << "@version:: \"1.0\""
3264
+ lines << ""
3265
+ lines << "<<menu"
3266
+ lines << " style:: #{@style || 3}"
3267
+ lines << " banner_style:: #{@banner_style || 3}"
3268
+ lines << " font:: #{@style_config&.font || SetStyle.font}"
3269
+ lines << " animate:: #{@animate || 'rgb'}"
3270
+ lines << " center:: #{@center.nil? ? true : @center}"
3271
+ lines << " desc_prefix:: #{dp_val}"
3272
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3273
+ lines << " title:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3274
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3275
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3276
+ lines << " banner:: #{bn_cfg[:color]}:#{bn_cfg[:level]}"
3277
+ lines << " subtitle:: #{s_cfg[:color]}:#{s_cfg[:level]}"
3278
+ lines << " divider:: #{d_cfg[:color]}:#{d_cfg[:level]}"
3279
+ lines << ">>"
3280
+ lines << ""
3281
+ lines << "<<table"
3282
+ lines << " style:: #{@style || 3}"
3283
+ lines << " header_color:: yellow:2"
3284
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3285
+ lines << " selected_row:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3286
+ lines << " row_color:: white:1"
3287
+ lines << " zebra_striping:: true"
3288
+ lines << ">>"
3289
+ lines << ""
3290
+ lines << "<<card"
3291
+ lines << " style:: 7"
3292
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3293
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3294
+ lines << " content_color:: white:1"
3295
+ lines << ">>"
3296
+ lines << ""
3297
+ lines << "<<slider"
3298
+ lines << " style:: #{@style || 3}"
3299
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3300
+ lines << " fill_char:: █"
3301
+ lines << " empty_char:: ░"
3302
+ lines << ">>"
3303
+ lines << ""
3304
+ lines << "<<checkbox"
3305
+ lines << " style:: #{@style || 3}"
3306
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3307
+ lines << " checked_mark:: [X]"
3308
+ lines << " unchecked_mark:: [ ]"
3309
+ lines << ">>"
3310
+ lines << ""
3311
+ File.write(path, lines.join("\n") + "\n")
3312
+ path
3313
+ end
3314
+ alias_method :export_theme, :export_config
3315
+
2219
3316
  def draw(size_max: 20, min_width: nil)
3317
+ if ARGV.any? { |a| ["-theme", "--theme", "-ex", "--export-theme"].include?(a.to_s.downcase) }
3318
+ out_idx = ARGV.index { |a| ["-o", "--out", "--output"].include?(a.to_s.downcase) }
3319
+ target_file = out_idx ? ARGV[out_idx + 1] : "tema_exportado.gr"
3320
+ export_config(target_file)
3321
+ Kernel.puts Color.bright_green("[OK] Tema exportado exitosamente a: #{target_file}")
3322
+ exit(0)
3323
+ end
3324
+
2220
3325
  target_width = min_width || size_max || 20
2221
3326
  action_to_execute = nil
2222
3327
 
@@ -2225,6 +3330,11 @@ class GRmenu
2225
3330
  begin
2226
3331
  Kernel.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2227
3332
 
3333
+ if @animate && !["false", "rgb", "", "nil"].include?(@animate.downcase)
3334
+ intro_lines = render_lines(target_width)
3335
+ self.class.animate_render(intro_lines, @animate)
3336
+ end
3337
+
2228
3338
  if is_tty
2229
3339
  $stdin.raw do |raw_input_stream|
2230
3340
  action_to_execute = run_interactive_loop(raw_input_stream, target_width)
@@ -2266,7 +3376,7 @@ class GRmenu
2266
3376
  @rgb_tick = 0.0
2267
3377
  draw_frame(target_width)
2268
3378
 
2269
- animating = has_rgb_animation?
3379
+ animating = has_active_animation?
2270
3380
 
2271
3381
  while true
2272
3382
  if animating