6 # an amazing widget manager for an awesome window manager
8 # Usage: amazing [options]
9 # -c, --config FILE Configuration file (~/.amazing.yml)
10 # -s, --screen ID Screen ID (0)
11 # -l, --log-level LEVEL Severity threshold (info)
12 # -i, --include SCRIPT Include a widgets script
13 # -u, --update WIDGET Update a widget and exit
14 # -w, --list-widgets List available widgets
15 # -h, --help You're looking at it
19 # * Battery: Remaining battery power in percentage
20 # * Maildir: Mail count in maildirs
21 # * ALSA: Various data for the ALSA mixer
22 # * Raggle: Unread posts in raggle
23 # * Memory: Various memory related data
24 # * Clock: Displays date and time
63 # In this example tb_mail doesn't have an "every" setting and is instead
64 # updated manually with <tt>amazing -u tb_mail</tt>, perhaps in cron after fetching
65 # new mail via fdm, getmail, fetchmail or similar. A good idea is also to
66 # update after closing your MUA such as Mutt which could be done with
67 # shell functions, example:
76 # Widgets inherit from Widget, serves data via instance methods, signalizes
77 # errors by raising a WidgetError and processes widget options via @options.
78 # The init method is used instead of initialize. Here's an example:
80 # class Clock < Widget
81 # description "Displays date and time"
82 # option :time_format, "Time format as described in DATE(1)", "%R"
83 # field :time, "Formatted time"
87 # @time = Time.now.strftime(@time_format)
88 # raise WidgetError, "An error occured!" if some_error?
92 # The ProcFile class can be used for parsing /proc files:
94 # cpuinfo = ProcFile.new("cpuinfo")
95 # cpuinfo[1]["model name"]
96 # #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
100 # * Maybe auto-include scripts from ~/.amazing/something
101 # * Self-documenting widgets (list fields and options) (done in widgets)
102 # * Some widgets need to support multiple data sources
103 # * Some way to do alerts, e.g. "blinking"
104 # * Make widget configuration screen specific
105 # * Support widgets with multiple bars and graphs (maybe wait for 2.3)
106 # * More widgets, duh
110 # Copyright (C) 2008 Dag Odenhall <dag.odenhall@gmail.com>
111 # Licensed under the Academic Free License version 3.0
112 # http://www.rosenlaw.com/AFL3.0.htm
126 # Raised by DisplayName#new if called with empty argument, or without
127 # argument and ENV["DISPLAY"] is empty.
128 class EmptyDisplayName < ArgumentError
131 # Raised by DisplayName#new if format of argument or ENV["DISPLAY"] is
133 class InvalidDisplayName < ArgumentError
136 # Parse an X11 display name
138 # display = DisplayName.new("hostname:displaynumber.screennumber")
139 # display.hostname #=> "hostname"
140 # display.display #=> "displaynumber"
141 # display.screen #=> "screennumber"
143 # Without arguments, reads ENV["DISPLAY"]. With empty argument or
144 # DISPLAY environment, raises EmptyDisplayName. With invalid display name
145 # format, raises InvalidDisplayName.
147 attr_reader :hostname, :display, :screen
149 def initialize(display_name=ENV["DISPLAY"])
150 raise EmptyDisplayName, "No display name supplied" if ["", nil].include? display_name
151 @hostname, @display, @screen = display_name.scan(/^(.*):(\d+)(?:\.(\d+))?$/)[0]
152 raise InvalidDisplayName, "Invalid display name" if @display.nil?
153 @hostname = "localhost" if @hostname.empty?
154 @screen = "0" unless @screen
159 # Communicate with awesome
161 # awesome = Awesome.new
162 # awesome.widget_tell(widget_id, "Hello, world")
163 # awesome = Awesome.new(1)
164 # awesome.tag_view(3)
165 # Awesome.new.client_zoom
167 attr_accessor :screen, :display
169 def initialize(screen=0, display=0)
170 @screen = screen.to_i
174 def method_missing(method, *args)
175 IO.popen("env DISPLAY=#{display} awesome-client", IO::WRONLY) do |ac|
176 ac.puts "#@screen #{method} #{args.join(' ')}"
183 # cpuinfo = ProcFile.new("cpuinfo")
184 # cpuinfo[1]["model name"]
185 # #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
190 file = "/proc/#{file}" if file[0] != ?/
192 File.readlines(file).each do |line|
193 if sep = line.index(":")
194 @list[-1][line[0..sep-1].strip] = line[sep+1..-1].strip
199 @list.pop if @list[-1].empty?
203 @list.each do |section|
213 # Raised by widgets, and is then rescued and logged
214 class WidgetError < Exception
217 # Parent class for widget construction, example:
219 # class Clock < Widget
220 # description "Displays date and time"
221 # option :time_format, "Time format as described in DATE(1)", "%R"
222 # field :time, "Formatted time"
226 # @time = Time.now.strftime(@time_format)
227 # raise WidgetError, "An error occured!" if some_error?
231 def initialize(identifier=nil, format=nil, opts={})
232 @identifier, @format = identifier, format
233 self.class.options.each do |key, value|
234 value = opts[key.to_s] || value[:default]
235 instance_variable_set "@#{key}".to_sym, value
237 self.class.fields.each do |key, value|
238 instance_variable_set "@#{key}".to_sym, value[:default]
240 instance_eval(&self.class.init) if self.class.init
243 def self.description(description=nil)
245 @description = description
251 def self.option(name, description=nil, default=nil)
253 @options[name] = {:description => description, :default => default}
260 def self.field(name, description=nil, default=nil)
262 @fields[name] = {:description => description, :default => default}
269 def self.default(format=nil, &block)
279 def self.init(&block)
289 instance_eval(@format)
291 case self.class.default
293 instance_eval(&self.class.default)
295 instance_eval(self.class.default)
303 description "Various data for the ALSA mixer"
304 option :mixer, "ALSA mixer name", "Master"
305 field :volume, "Volume in percentage", 0
309 IO.popen("amixer get #@mixer", IO::RDONLY) do |am|
311 volumes = out.scan(/\[(\d+)%\]/).flatten
312 volumes.each {|vol| @volume += vol.to_i }
313 @volume = @volume / volumes.size
318 class Battery < Widget
319 description "Remaining battery power in percentage"
320 option :battery, "Battery number", 1
321 field :percentage, "Power percentage", 0
322 default "@percentage"
325 batinfo = ProcFile.new("acpi/battery/BAT#@battery/info")[0]
326 batstate = ProcFile.new("acpi/battery/BAT#@battery/state")[0]
327 remaining = batstate["remaining capacity"].to_i
328 lastfull = batinfo["last full capacity"].to_i
329 @percentage = (remaining * 100) / lastfull.to_f
334 description "Displays date and time"
335 option :time_format, "Time format as described in DATE(1)", "%R"
336 field :time, "Formatted time"
340 @time = Time.now.strftime(@time_format)
344 class Maildir < Widget
345 description "Mail count in maildirs"
346 option :directories, "Globs of maildirs" # TODO: does a default make sense?
347 field :count, "Ammount of mail in searched directories", 0
351 raise WidgetError, "No directories configured" unless @directories
352 @directories.each do |glob|
353 glob = "#{ENV["HOME"]}/#{glob}" if glob[0] != ?/
354 @count += Dir["#{glob}/*"].size
359 class Memory < Widget
360 description "Various memory related data"
361 field :total, "Total kilobytes of memory", 0
362 field :free, "Free kilobytes of memory", 0
363 field :buffers, nil, 0 # TODO: description
364 field :cached, nil, 0 # TODO: description
365 field :usage, "Percentage of used memory", 0
369 meminfo = ProcFile.new("meminfo")[0]
370 @total = meminfo["MemTotal"].to_i
371 @free = meminfo["MemFree"].to_i
372 @buffers = meminfo["Buffers"].to_i
373 @cached = meminfo["Cached"].to_i
374 @usage = ((@total - @free - @cached - @buffers) * 100) / @total.to_f
378 class Raggle < Widget
379 description "Unread posts in raggle"
380 option :feed_list_path, "Path to feeds list", ".raggle/feeds.yaml"
381 option :feed_cache_path, "Path to feeds cache", ".raggle/feed_cache.store"
382 field :count, "Ammount of unread posts", 0
386 @feed_list_path = "#{ENV["HOME"]}/#@feed_list_path" if @feed_list_path[0] != ?/
387 feeds = YAML.load_file(@feed_list_path)
388 @feed_cache_path = "#{ENV["HOME"]}/#{@feed_cache_path}" if @feed_cache_path[0] != ?/
389 cache = PStore.new(@feed_cache_path)
390 cache.transaction(false) do
392 cache[feed["url"]].each do |item|
393 @count += 1 unless item["read?"]
401 # Parse and manage command line options
407 @options[:config] = "#{ENV["HOME"]}/.amazing.yml"
408 @options[:screens] = []
409 @options[:loglevel] = "info"
410 @options[:include] = []
411 @options[:update] = []
413 @parser = OptionParser.new do |opts|
414 opts.on("-c", "--config FILE", "Configuration file (~/.amazing.yml)") do |config|
415 @options[:config] = config
417 opts.on("-s", "--screen ID", "Screen ID (0)") do |screen|
418 @options[:screens] << screen
420 opts.on("-l", "--log-level LEVEL", "Severity threshold (info)") do |level|
421 @options[:loglevel] = level
423 opts.on("-i", "--include SCRIPT", "Include a widgets script") do |script|
424 @options[:include] << script
426 opts.on("-u", "--update WIDGET", "Update a widget and exit") do |widget|
427 @options[:update] << widget
429 opts.on("-w", "--list-widgets", "List available widgets") do
430 @options[:listwidgets] = true
432 opts.on("-h", "--help", "You're looking at it") do
433 @options[:help] = true
439 @options.keys.each do |key|
444 def parse(args=@args)
456 def []=(option, value)
457 @options[option] = value
461 # Command line interface runner
467 @log = Logger.new(STDOUT)
468 @options = Options.new(@args)
470 @display = X11::DisplayName.new
471 rescue X11::EmptyDisplayName => e
472 @log.warn("#{e.message}, falling back on :0")
473 @display = X11::DisplayName.new(":0")
474 rescue X11::InvalidDisplayName => e
475 @log.fatal("#{e.message}, exiting")
482 @log.fatal("Received SIGINT, exiting")
486 show_help if @options[:help]
490 list_widgets if @options[:listwidgets]
493 explicit_updates unless @options[:update].empty?
497 @config["widgets"].each do |widget_name, settings|
498 if settings["every"] && count % settings["every"] == 0
499 update_widget(widget_name)
516 @log.level = Logger.const_get(@options[:loglevel].upcase)
518 @log.error("Unsupported log level #{@options[:loglevel].inspect}")
519 @log.level = Logger::INFO
524 scripts = @options[:include]
525 @config["include"].each do |script|
526 script = "#{File.dirname(@options[:config])}/#{script}" if script[0] != ?/
529 scripts.each do |script|
530 if File.exist?(script)
531 Widgets.module_eval(File.read(script))
533 @log.error("No such widget script #{script.inspect}")
539 Widgets.constants.each do |widget|
540 if description = Widgets.const_get(widget).description
541 puts "#{widget}: #{description}"
550 @log.debug("Parsing configuration file")
552 @config = YAML.load_file(@options[:config])
554 @log.fatal("Unable to parse configuration file, exiting")
557 @config["include"] ||= []
558 @config["screens"] ||= []
563 @options[:screens].each do |screen|
564 @screens[screen.to_i] = Awesome.new(screen, @display.display)
567 @config["screens"].each do |screen|
568 @screens[screen] = Awesome.new(screen, @display.display)
571 @screens[0] = Awesome.new if @screens.empty?
575 @log.debug("Waiting for awesome control socket for display #{@display.display}")
577 Timeout.timeout(30) do
578 sleep 1 until File.exist?("#{ENV["HOME"]}/.awesome_ctl.#{@display.display}")
579 @log.debug("Got socket for display #{@display.display}")
581 rescue Timeout::Error
582 @log.fatal("Socket for display #{@display.display} not created within 30 seconds, exiting")
587 def update_non_interval
588 @config["widgets"].each do |widget_name, settings|
589 next if settings["every"]
590 update_widget(widget_name)
595 @config["widgets"].each_key do |widget_name|
596 next unless @options[:update].include? widget_name
597 update_widget(widget_name, false)
602 def update_widget(widget_name, threaded=true)
603 settings = @config["widgets"][widget_name]
605 @screens.each do |screen, awesome|
606 @log.debug("Updating widget #{widget_name} of type #{settings["type"]} on screen #{screen}")
607 opts = settings["options"] || {}
608 field = settings["field"] || "default"
610 widget = Widgets.const_get(settings["type"]).new(widget_name, settings["format"], opts)
611 awesome.widget_tell(widget_name, widget.formatize)
619 rescue WidgetError => e
620 @log.error(settings["type"]) { e.message }
627 Amazing::CLI.new(ARGV).run